diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index abff425f..9bfccf55 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,6 +18,8 @@ "UV_PROJECT_ENVIRONMENT": "/app/.venv" }, + "initializeCommand": "if docker info 2>/dev/null | grep -q rootless; then DS=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock\"; else DS=\"/var/run/docker.sock\"; fi; ln -sf \"$DS\" .docker.sock", + // The optional 'workspaceFolder' property is the path VS Code should open by default when // connected. This is typically a file mount in .devcontainer/docker-compose.yml "workspaceFolder": "/app", @@ -36,7 +38,7 @@ } }, - "postCreateCommand": "uv sync --dev --locked", + "postCreateCommand": "uv sync --dev", // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, diff --git a/.dockerignore b/.dockerignore index 319555f9..2b0a625d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,6 @@ **/dist **/node_modules .env -.flake8 .git .gitignore .github diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..42c152ba --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,205 @@ +name: CI +on: [push] +jobs: + pytest: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + + - name: Install dependencies + # we use --locked instead of --frozen to elicit an error if pyproject.toml and uv.lock are not synced + run: uv sync --dev --locked + + - name: Run pytest + run: uv run pytest --ignore=test/availability + + - name: Generate badges + if: always() + run: | + PASSED=$(python -c "import xml.etree.ElementTree as ET; r=ET.parse('junit.xml').getroot(); ts=r.findall('testsuite'); print(sum(int(t.get('tests',0))-int(t.get('failures',0))-int(t.get('errors',0)) for t in ts))") + FAILED=$(python -c "import xml.etree.ElementTree as ET; r=ET.parse('junit.xml').getroot(); ts=r.findall('testsuite'); print(sum(int(t.get('failures',0))+int(t.get('errors',0)) for t in ts))") + if [ "$FAILED" -eq 0 ]; then + uv run anybadge --label=pytest --value="${PASSED} passed" --color=green --file=pytest.svg + else + uv run anybadge --label=pytest --value="${PASSED} passed, ${FAILED} failed" --color=red --file=pytest.svg + fi + COVERAGE=$(python -c "import xml.etree.ElementTree as ET; t=ET.parse('coverage.xml').getroot(); print(round(float(t.get('line-rate',0))*100))") + uv run anybadge --value=$COVERAGE --file=coverage.svg coverage + + - name: Upload pytest badge + if: always() + uses: actions/upload-artifact@v7 + with: + name: pytest + path: pytest.svg + overwrite: true + + - name: Upload coverage badge + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage + path: coverage.svg + overwrite: true + + + ty: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --dev --frozen + + - name: Run ty + run: uv run ty check + + - name: Generate badge + if: always() + run: | + if [ "${{ job.status }}" = "success" ]; then + uv run anybadge --label=ty --value=passing --color=green --file=ty.svg + else + uv run anybadge --label=ty --value=failing --color=red --file=ty.svg + fi + + - name: Upload ty badge + if: always() + uses: actions/upload-artifact@v7 + with: + name: ty + path: ty.svg + overwrite: true + + + ruff: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --dev --frozen + + - name: Lint with ruff + run: uv run ruff check --output-format=concise > ruff.txt + + - name: Generate badge + if: always() + run: | + VIOLATIONS=$(tail -1 ruff.txt | grep -oP '\d+' || echo 0) + if [ "$VIOLATIONS" -eq 0 ]; then + uv run anybadge --label=ruff --value=passing --color=green --file=ruff.svg + else + uv run anybadge --label=ruff --value="$VIOLATIONS violations" --color=orange --file=ruff.svg + fi + + - name: Upload ruff badge + if: always() + uses: actions/upload-artifact@v7 + with: + name: ruff + path: ruff.svg + overwrite: true + + + badges: + needs: [pytest, ty, ruff] + if: always() && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + ref: badges + path: badges-branch + + - uses: actions/download-artifact@v7 + with: + name: pytest + path: badges-branch + + - uses: actions/download-artifact@v7 + with: + name: coverage + path: badges-branch + + - uses: actions/download-artifact@v7 + with: + name: ty + path: badges-branch + + - uses: actions/download-artifact@v7 + with: + name: ruff + path: badges-branch + + - name: Commit and push badges + working-directory: badges-branch + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add pytest.svg coverage.svg ty.svg ruff.svg + git diff --staged --quiet || git commit -m "ci: update badges" + git push + + + build: + needs: [pytest, ty, ruff] + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve build metadata + id: meta + run: | + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + echo "additional_tag=latest" >> $GITHUB_OUTPUT + elif [[ "$GITHUB_REF_NAME" == "beta" ]]; then + echo "version=beta" >> $GITHUB_OUTPUT + echo "additional_tag=" >> $GITHUB_OUTPUT + else + echo "version=dev" >> $GITHUB_OUTPUT + echo "additional_tag=" >> $GITHUB_OUTPUT + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Build and push + run: | + docker buildx bake all --push \ + --set "*.cache-from=type=gha" \ + --set "*.cache-to=type=gha,mode=max" + env: + BUGHOG_VERSION: ${{ steps.meta.outputs.version }} + ADDITIONAL_TAG: ${{ steps.meta.outputs.additional_tag }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 00000000..d75b2448 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,23 @@ +name: Create GitHub release + +on: + create: + +jobs: + package_release: + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: ${{ github.ref }} + draft: true + prerelease: false diff --git a/.github/workflows/run-tests-and-linter.yml b/.github/workflows/run-tests-and-linter.yml deleted file mode 100644 index 4dd8c8e0..00000000 --- a/.github/workflows/run-tests-and-linter.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Pytest CI/CD -on: [push] -jobs: - pytest: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v5 - - - name: Install uv - uses: astral-sh/setup-uv@v6 - with: - enable-cache: true - - - name: Install dependencies - run: uv sync --dev --locked - - - name: Run pytest - run: uv run pytest --ignore=test/availability - - - name: Generate badge - run: | - uv run genbadge tests -i junit.xml -o pytest.svg - uv run genbadge coverage -i coverage.xml -o coverage.svg - - - name: Upload test badge - uses: actions/upload-artifact@v4 - with: - name: pytest - path: pytest.svg - overwrite: true - - - name: Upload coverage badge - uses: actions/upload-artifact@v4 - with: - name: coverage - path: coverage.svg - overwrite: true - - - flake8: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v5 - - - name: Install uv - uses: astral-sh/setup-uv@v6 - with: - enable-cache: true - - - name: Install dependencies - run: | - uv sync --dev --locked - - - name: Lint with flake8 - run: | - uv run flake8 --exit-zero --output-file flake8.txt - - - name: Generate badge - run: | - uv run genbadge flake8 -i flake8.txt -o flake8.svg - - - name: Upload badge - uses: actions/upload-artifact@v4 - with: - name: flake8 - path: flake8.svg - overwrite: true diff --git a/.github/workflows/trigger-dev-build-job.yml b/.github/workflows/trigger-dev-build-job.yml deleted file mode 100644 index e40b76d0..00000000 --- a/.github/workflows/trigger-dev-build-job.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Trigger GitLab dev build job -on: - push: - branches: - - dev -env: - GITLAB_PIPELINE_TRIGGER_TOKEN: ${{ secrets.GITLAB_PIPELINE_TRIGGER_TOKEN }} -jobs: - trigger_build_job: - runs-on: ubuntu-latest - steps: - - name: send triggering request - run: | - curl -X POST \ - --fail \ - -F "token=$GITLAB_PIPELINE_TRIGGER_TOKEN" \ - -F "ref=main" \ - -F "variables[REPO]=${{github.repository}}" \ - https://gitlab.kuleuven.be/api/v4/projects/17581/trigger/pipeline diff --git a/.github/workflows/trigger-release-build-job.yml b/.github/workflows/trigger-release-build-job.yml deleted file mode 100644 index d1b682b7..00000000 --- a/.github/workflows/trigger-release-build-job.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Trigger GitLab release build job -on: - # Triggers the workflow on push or pull request events but only for the main branch - create: -env: - GITLAB_PIPELINE_TRIGGER_TOKEN: ${{ secrets.GITLAB_PIPELINE_TRIGGER_TOKEN }} -jobs: - - trigger_build_job: - if: ${{ startsWith(github.ref, 'refs/tags/v') }} - runs-on: ubuntu-latest - steps: - - name: send triggering request - run: | - curl -X POST \ - --fail \ - -F "token=$GITLAB_PIPELINE_TRIGGER_TOKEN" \ - -F "ref=main" \ - -F "variables[REPO]=${{github.repository}}" \ - -F "variables[RELEASE_TAG]=${{github.ref_name}}" \ - https://gitlab.kuleuven.be/api/v4/projects/17581/trigger/pipeline - - package_release: - if: ${{ startsWith(github.ref, 'refs/tags/v') }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} - draft: true - prerelease: false diff --git a/.gitignore b/.gitignore index 2cca31d9..de2d382f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Docker socket symlink +.docker.sock + database/data/ nginx/ssl/certs/* diff --git a/.vscode/launch.json b/.vscode/launch.json index f8d0cfe3..ad29eccb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,10 +16,23 @@ ], "env": { "PYTHONPATH": "${workspaceFolder}", - "DISPLAY": ":1", - "MOZ_DISABLE_CONTENT_SANDBOX": "1", "PATH":"${PATH}:$HOME/.local/bin" } + }, + { + "name": "BugHog: Attach Worker (Dev Container)", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "bh_worker_0", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}/bughog", + "remoteRoot": "/app/bughog" + } + ] } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 0cb6b213..b9e39ed1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,15 +3,8 @@ "python.testing.pytestArgs": [ "test" ], - "python.testing.pytestEnabled": false, - "python.testing.unittestArgs": [ - "-v", - "-s", - "./test", - "-p", - "test_*.py" - ], - "python.testing.unittestEnabled": true, + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", "editor.rulers": [100, 120], diff --git a/Dockerfile b/Dockerfile index 774dc87d..573fa742 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,12 +7,8 @@ RUN npm run build FROM openresty/openresty:1.27.1.1-3-bullseye AS nginx -RUN apt update -y && \ - apt install -y curl && \ - rm -rf /var/lib/apt/lists/* -RUN mkdir -p /www/data/js && \ - curl https://cdn.bokeh.org/bokeh/release/bokeh-3.6.1.min.js -o /www/data/js/bokeh.min.js && \ - curl https://cdn.bokeh.org/bokeh/release/bokeh-api-3.6.1.min.js -o /www/data/js/bokeh-api.min.js +ADD --chmod=755 https://cdn.bokeh.org/bokeh/release/bokeh-3.8.2.min.js /www/data/js/bokeh.min.js +ADD --chmod=755 https://cdn.bokeh.org/bokeh/release/bokeh-api-3.8.2.min.js /www/data/js/bokeh-api.min.js COPY ./nginx/start.sh /usr/local/bin/ COPY ./nginx/config /etc/nginx/config COPY --from=ui-build-stage /app/dist /www/data @@ -21,61 +17,51 @@ CMD ["start.sh"] FROM python:3.13-slim-bullseye AS base -COPY --from=ghcr.io/astral-sh/uv:0.9.7 /uv /uvx /bin/ + WORKDIR /app +ENV PATH="/app/.venv/bin:$PATH" -RUN apt-get update -RUN apt install -y curl gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libnspr4 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils libgbm-dev xvfb dbus-x11 libnss3-tools python3-tk python3-xlib gnome-screenshot vim git procps &&\ - rm -rf /var/lib/apt/lists/* -# Install Docker -RUN curl -sSL https://get.docker.com/ | sh - -# We manually install deprecated libraries for older browser versions. -# Most of them are Debian 11 (buster) packages. -# Stuff needed for chrome versions < 40 -RUN curl -sSLo multiarch-support.deb https://snapshot.debian.org/archive/debian-security/20240630T105336Z/pool/updates/main/g/glibc/multiarch-support_2.28-10%2Bdeb10u4_amd64.deb &&\ - curl -sSLo libgcrypt11.deb https://snapshot.debian.org/archive/debian/20130820T215153Z/pool/main/libg/libgcrypt11/libgcrypt11_1.5.3-2_amd64.deb &&\ - curl -sSLo libudev0.deb https://snapshot.debian.org/archive/debian/20111118T151858Z/pool/main/u/udev/libudev0_175-2_amd64.deb &&\ - curl -sSLo libpng12.deb https://snapshot.debian.org/archive/debian/20151118T214328Z/pool/main/libp/libpng/libpng12-0_1.2.54-1_amd64.deb &&\ - curl -sSLo pango1.0.deb https://snapshot.debian.org/archive/debian/20200504T084128Z/pool/main/p/pango1.0/libpango-1.0-0_1.42.4-8~deb10u1_amd64.deb &&\ - curl -sSLo libpangocairo-1.deb https://snapshot.debian.org/archive/debian/20210326T204420Z/pool/main/p/pango1.0/libpangocairo-1.0-0_1.42.4-8~deb10u1_amd64.deb &&\ - curl -sSLo libpangoft2.deb https://snapshot.debian.org/archive/debian/20210326T204420Z/pool/main/p/pango1.0/libpangoft2-1.0-0_1.42.4-8~deb10u1_amd64.deb &&\ - curl -sSLo libgtk-3.deb https://snapshot.debian.org/archive/debian/20210326T204420Z/pool/main/g/gtk+3.0/libgtk-3-0_3.24.5-1_amd64.deb &&\ - dpkg -i multiarch-support.deb &&\ - dpkg -i libgtk-3.deb libpangoft2.deb libpangocairo-1.deb pango1.0.deb libgcrypt11.deb libudev0.deb libpng12.deb &&\ - rm multiarch-support.deb libgtk-3.deb libpangoft2.deb libpangocairo-1.deb pango1.0.deb libgcrypt11.deb libudev0.deb libpng12.deb &&\ -# Stuff needed for chrome versions < 17 - ln -s /usr/lib/x86_64-linux-gnu/libnss3.so /usr/lib/x86_64-linux-gnu/libnss3.so.1d &&\ - ln -s /usr/lib/x86_64-linux-gnu/libnssutil3.so /usr/lib/x86_64-linux-gnu/libnssutil3.so.1d &&\ - ln -s /usr/lib/x86_64-linux-gnu/libsmime3.so /usr/lib/x86_64-linux-gnu/libsmime3.so.1d &&\ - ln -s /usr/lib/x86_64-linux-gnu/libssl3.so /usr/lib/x86_64-linux-gnu/libssl3.so.1d &&\ - ln -s /usr/lib/x86_64-linux-gnu/libplds4.so /usr/lib/x86_64-linux-gnu/libplds4.so.0d &&\ - ln -s /usr/lib/x86_64-linux-gnu/libplc4.so /usr/lib/x86_64-linux-gnu/libplc4.so.0d &&\ - ln -s /usr/lib/x86_64-linux-gnu/libnspr4.so /usr/lib/x86_64-linux-gnu/libnspr4.so.0d - -COPY subject/web_browser/profiles /app/subject/web_browser/profiles -COPY --chmod=0755 scripts/ /app/scripts/ -RUN cp /app/scripts/daemon/xvfb /etc/init.d/xvfb &&\ - mkdir -p /app/logs +FROM base AS python-app + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +COPY --from=ghcr.io/astral-sh/uv:0.11.7 /uv /bin/ -# Install python packages COPY pyproject.toml uv.lock /app/ -RUN uv sync --no-dev --locked -ENV PATH="/app/.venv/bin:$PATH" +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev --frozen --no-install-project -# Initiate PyAutoGUI -RUN touch /root/.Xauthority && \ - xauth add ${HOST}:0 . $(xxd -l 16 -p /dev/urandom) +COPY --chmod=0755 scripts/ /app/scripts/ +COPY bughog /app/bughog/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev --frozen FROM base AS core -# Copy rest of source code -COPY bughog /app/bughog + +# Install docker cli, and git for development container +RUN apt-get update && \ + apt-get install -y curl git gnupg && \ + curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/docker.gpg] https://download.docker.com/linux/debian bullseye stable" \ + > /etc/apt/sources.list.d/docker.list && \ + apt-get update && \ + apt-get install -y docker-ce-cli && \ + apt-get remove -y --autoremove curl gnupg && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=python-app /bin/uv /bin/uv +COPY --from=python-app /app /app + ENTRYPOINT [ "/app/scripts/boot/core.sh" ] FROM base AS worker -# Copy rest of source code -COPY bughog /app/bughog + +COPY --from=python-app /bin/uv /bin/uv +COPY --from=python-app /app /app + ENTRYPOINT [ "/app/scripts/boot/worker.sh" ] diff --git a/README.md b/README.md index c2a59441..9cda3a06 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,20 @@
BugHog logo
- pytest_job + pytest + coverage + ty + ruff +
Docker Image Version (tag) - Docker Image Size + Docker Image Size + Docker Image Size

-BugHog is a powerful framework designed specifically to address the challenging task of pinpointing the exact code revisions in which a particular browser bug was introduced or fixed. +BugHog is a framework for pinpointing the exact code commit in which a browser bug was introduced or fixed. +Given a proof-of-concept (PoC) that reproduces a bug, BugHog automatically bisects across browser builds to find the precise commit where the behaviour changed. This framework has been developed as part of the _"A Bug's Life: Analyzing the Lifecycle and Mitigation Process of Content Security Policy Bugs"_ paper to identify Content Security Policy bug lifecycles, published at [USENIX Security '23](https://www.usenix.org/conference/usenixsecurity23/presentation/franken). Since then, it has continued to evolve and has been exhibited at major cybersecurity conferences, including [Black Hat USA](https://www.blackhat.com/us-24/arsenal/schedule/index.html#bughog-38604). @@ -29,23 +35,31 @@ Since then, it has continued to evolve and has been exhibited at major cybersecu +## How it works + +1. **Write a PoC** — create a minimal HTML/JS file that demonstrates the bug (e.g. a CSP bypass, a rendering glitch, a JS engine crash). +2. **Select a subject and commit range** — choose a browser or engine and the range of builds to test across. +3. **Run BugHog** — it automatically executes your PoC against each build and bisects to find the exact commit where the bug appeared or disappeared. +4. **Inspect results** — the web UI shows a timeline of pass/fail results and highlights the introducing or fixing commit. + + ## Getting started :rocket: -BugHog is compatible with UNIX systems running Docker, including WSL on Windows. -You will need at least 5 GB of disk space. +### Prerequisites + +- A UNIX-based system (Linux or macOS). Windows is supported via WSL — clone the repository into the WSL filesystem, not the Windows filesystem. +- Docker installed and running. +- At least 5 GB of free disk space. -Follow these steps to get started: +### Installation ```bash # Clone this repository git clone https://github.com/DistriNet/BugHog cd BugHog -# Pull our pre-built Docker images -./scripts/pull.sh - -# Start the pulled images -./scripts/start.sh +# Pull the latest pre-built images from Docker Hub and start BugHog +./scripts/deploy.sh ``` Open your web browser and navigate to [http://localhost:80](http://localhost:80) to access the graphical user interface. @@ -55,12 +69,12 @@ If BugHog is started on a remote server, substitute 'localhost' with the appropr > Depending on your Docker configuration, you might have to use `sudo ./scripts/[..]`. > > BugHog in default configuration will spin up its own MongoDB container, which persists data in the [/database](/database/) folder. -> Configuring BugHog to use your own MongoDB and other options are explained [here](https://github.com/DistriNet/BugHog/wiki/Configuration-options). +> Configuring BugHog to use your own MongoDB and other options are explained [here](/docs/CONFIGURATION.md). > [!TIP] -> Our [30-minute tutorial](https://github.com/DistriNet/BugHog/wiki/Tutorial) will guide you on how to use BugHog to trace a real bug's lifecycle! +> Our [30-minute tutorial](/docs/TUTORIAL.md) will guide you on how to use BugHog to trace a real bug's lifecycle! -To stop BugHog, simply run this in the project root: +To stop BugHog (including any running worker containers), run this in the project root: ```bash ./scripts/stop.sh @@ -69,33 +83,28 @@ To stop BugHog, simply run this in the project root: ## Development :hammer_and_wrench: -Use the following commands to build the Docker images yourself, for instance after you made changes to the source code: +Use the following command to build all Docker images from the current codebase and start BugHog, for instance after you made changes to the source code: ```bash -# Build BugHog images -./scripts/build.sh - -# Run the freshly built images -./scripts/start.sh +./scripts/build-deploy.sh ``` -> [!NOTE] -> For reference, building takes about 4 minutes on a machine with 8 CPU cores and 8 GB of RAM. +This builds all images (core, nginx, and all subject workers) tagged as `dev`. ### Debugging -The most convenient debugging approach is to launch an interactive Node environment. -The UI can be visited at [http://localhost:5173](http://localhost:5173). - -```bash -./scripts/node_dev.sh -``` For debugging the core application, consider using the VS Code dev container. You can utilize the configuration in [.devcontainer](.devcontainer) for this. +## Contributing + +Contributions are welcome! +Please open a [GitHub issue](https://github.com/DistriNet/BugHog/issues/new) to report bugs or propose features, or submit a pull request with your changes. + + ## Support and contact :phone: More information on how to use BugHog can be found [here](/docs/SUPPORT.md). diff --git a/bughog/analysis/plot_factory.py b/bughog/analysis/plot_factory.py index cb23a87a..a137219d 100644 --- a/bughog/analysis/plot_factory.py +++ b/bughog/analysis/plot_factory.py @@ -13,7 +13,7 @@ class PlotFactory: @staticmethod def get_plot_commit_data(params: EvaluationParameters) -> dict: commit_docs = MongoDB().get_documents_for_plotting(params) - state_oracle = factory.get_subject_from_params(params).state_oracle + state_oracle = factory.get_subject_from_params(params.subject_configuration).state_oracle return PlotFactory.__add_outcome_info(commit_docs, state_oracle) @staticmethod @@ -24,7 +24,7 @@ def get_plot_release_data(params: EvaluationParameters) -> dict: @staticmethod def validate_params(params: EvaluationParameters) -> list[str]: missing_parameters = [] - if not params.evaluation_range.experiment_name: + if not params.experiment_name: missing_parameters.append('selected experiment') if not params.subject_configuration.subject_type: missing_parameters.append('subject_type') @@ -43,7 +43,7 @@ def __transform_to_bokeh_compatible(docs: list) -> dict: return new_docs @staticmethod - def __add_outcome_info(docs: list, state_oracle: StateOracle|None): + def __add_outcome_info(docs: list, state_oracle: StateOracle | None): if not docs: return {'commit_nb': [], 'major_version': [], 'version_printed_by_executable': [], 'outcome': []} @@ -63,7 +63,9 @@ def __add_outcome_info(docs: list, state_oracle: StateOracle|None): logger.error(f'Skipping state doc with unknown commit number (commit id: {commit_id}).') continue elif commit_id is None: - logger.error(f'Including state doc with unknown commit id (commit number: {commit_nb}), without supplying commit url.') + logger.error( + f'Including state doc with unknown commit id (commit number: {commit_nb}), without supplying commit url.' + ) commit_url = None else: if state_oracle: @@ -74,7 +76,7 @@ def __add_outcome_info(docs: list, state_oracle: StateOracle|None): new_doc = { 'commit_nb': commit_nb, 'commit_url': commit_url, - 'major_version': doc['state'].get('major_version', None), # commit states don't have this field + 'major_version': doc['state'].get('major_version', None), # commit states don't have this field 'version_printed_by_executable': doc['subject_version'], } if doc['dirty']: diff --git a/bughog/app.py b/bughog/app.py index 4cc5c9b6..960613cb 100644 --- a/bughog/app.py +++ b/bughog/app.py @@ -5,7 +5,7 @@ from flask import Flask from flask_sock import Sock -from bughog import configuration +from bughog import config from bughog.main import Main sock = Sock() @@ -15,9 +15,9 @@ def create_app(): try: - configuration.Loggers.configure_loggers() + config.Loggers.configure_loggers() - if not configuration.check_required_env_parameters(): + if not config.check_required_env_parameters(): raise Exception('Not all required environment variables are available') # Instantiate main object and add to global flask context diff --git a/bughog/configuration.py b/bughog/config.py similarity index 59% rename from bughog/configuration.py rename to bughog/config.py index 723956ec..5a64eba0 100644 --- a/bughog/configuration.py +++ b/bughog/config.py @@ -4,6 +4,10 @@ import sys from functools import lru_cache +import docker +import docker.errors +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict from rich.logging import RichHandler from bughog.database.mongo import container @@ -13,6 +17,22 @@ custom_page_folder = '/app/experiments/pages' +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix='BUGHOG_') + + version: str | None = None + github_token: str | None = None + experiment_tries: int = Field(default=3, gt=0) + executable_cache_limit: int = Field(default=0, ge=0) + mongo_host: str | None = None + mongo_username: str | None = None + mongo_password: str | None = None + mongo_database: str | None = None + + +settings = Settings() + + def get_available_domains() -> list[str]: return [ 'a.test', @@ -36,12 +56,13 @@ def check_required_env_parameters() -> bool: else: logger.debug(f'HOST_PWD={host_pwd}') - # BUGHOG_VERSION - if (bughog_version := os.getenv('BUGHOG_VERSION')) in ['', None]: - logger.fatal('"BUGHOG_VERSION" variable is not set.') + # Version tag + try: + tag = get_tag() + logger.info(f'Starting BugHog with tag "{tag}"') + except ValueError as e: + logger.fatal(str(e)) fatal = True - else: - logger.info(f'Starting BugHog with tag "{bughog_version}"') return not fatal @@ -49,47 +70,83 @@ def check_required_env_parameters() -> bool: # Singleton pattern with caching @lru_cache(maxsize=1) def get_database_params() -> DatabaseParameters: + host = settings.mongo_host + username = settings.mongo_username + password = settings.mongo_password + database = settings.mongo_database + + if not (host and username and password and database): + missing = [ + name + for name, val in [ + ('BUGHOG_MONGO_HOST', host), + ('BUGHOG_MONGO_USERNAME', username), + ('BUGHOG_MONGO_PASSWORD', password), + ('BUGHOG_MONGO_DATABASE', database), + ] + if not val + ] + logger.info(f'Could not find database parameters {missing}. Using database container...') + return container.run(settings.executable_cache_limit) + + logger.info(f"Found database environment variables '{username}@{host}/{database}'.") + return DatabaseParameters(host, username, password, database, settings.executable_cache_limit) + + +def _read_container_id() -> str | None: + """Reads the current Docker container ID from /proc/self/cgroup.""" try: - executable_cache_limit = int(os.getenv('BUGHOG_EXECUTABLE_CACHE_LIMIT', '0')) - except ValueError: - logger.warning("Invalid 'BUGHOG_EXECUTABLE_CACHE_LIMIT' provided; defaulting to 0.") - executable_cache_limit = 0 - - required_database_params = [ - 'BUGHOG_MONGO_HOST', - 'BUGHOG_MONGO_USERNAME', - 'BUGHOG_MONGO_DATABASE', - 'BUGHOG_MONGO_PASSWORD', - ] - env_vars = {key: os.getenv(key) for key in required_database_params} - missing_database_params = [key for key, val in env_vars.items() if not val] - if missing_database_params: - logger.info(f'Could not find database parameters {missing_database_params}. Using database container...') - return container.run(executable_cache_limit) - - safe_env_vars = env_vars.copy() - safe_env_vars['BUGHOG_MONGO_PASSWORD'] = '*' - logger.info(f"Found database environment variables '{safe_env_vars}'.") - - return DatabaseParameters( - env_vars['BUGHOG_MONGO_HOST'] or '', - env_vars['BUGHOG_MONGO_USERNAME'] or '', - env_vars['BUGHOG_MONGO_PASSWORD'] or '', - env_vars['BUGHOG_MONGO_DATABASE'] or '', - executable_cache_limit, - ) + with open('/proc/self/cgroup') as f: + for line in f: + if '/docker/' in line: + return line.strip().split('/docker/')[-1] + except Exception: + pass + return None -@staticmethod +def _detect_tag_from_docker() -> str | None: + """ + Detects the BugHog image tag by inspecting the running container's image via the Docker API. + Looks for a tag of the form 'bughog/core:'. + """ + container_id = _read_container_id() + if not container_id: + return None + try: + client = docker.from_env() + running_container = client.containers.get(container_id) + for tag in running_container.image.tags: + if tag.startswith('bughog/core:'): + return tag.split(':', 1)[1] + except Exception: + pass + return None + + +@lru_cache(maxsize=1) def get_tag() -> str: """ - Returns the Docker image tag of BugHog. - This should never be empty. + Returns the Docker image tag of the running BugHog core container. + + Detection order: + 1. 'dev' — if DEVELOPMENT=1 (devcontainer / local dev workflow) + 2. Docker API — tag of the running container's image (e.g. 'bughog/core:1.2.3') + 3. BUGHOG_VERSION environment variable — manual override or fallback """ - bughog_version = os.getenv('BUGHOG_VERSION', None) - if bughog_version is None or bughog_version == '': - raise ValueError('BUGHOG_VERSION is not set') - return bughog_version + if os.getenv('DEVELOPMENT') == '1': + return 'dev' + tag = _detect_tag_from_docker() + if tag: + return tag + if settings.version: + logger.debug(f'Docker tag detection failed; using BUGHOG_VERSION override: {settings.version}') + return settings.version + raise ValueError( + 'Could not determine the BugHog version tag. ' + 'Ensure the core container image is tagged as "bughog/core:", ' + 'or set the BUGHOG_VERSION environment variable.' + ) class CustomHTTPHandler(logging.handlers.HTTPHandler): diff --git a/bughog/database/mongo/cache.py b/bughog/database/mongo/cache.py index d5854361..8b41498d 100644 --- a/bughog/database/mongo/cache.py +++ b/bughog/database/mongo/cache.py @@ -5,12 +5,12 @@ class Cache: - @staticmethod def cache_in_db(subject_type: str, subject_name: str, ttl: int = 0): """ Caches the result of the function in MongoDB, with respect to TTL (in hours). """ + def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): @@ -20,11 +20,9 @@ def wrapper(*args, **kwargs): key = args[0] if args else kwargs.get('key') collection = MongoDB().get_cache_collection(subject_type) - doc = collection.find_one({ - 'subject_name': subject_name, - 'function_name': func.__name__, - 'key': key - }) + doc = collection.find_one( + {'subject_name': subject_name, 'function_name': func.__name__, 'key': str(key)} + ) now = datetime.now(timezone.utc) # Check for cache existence and TTL @@ -37,7 +35,7 @@ def wrapper(*args, **kwargs): cached_time = datetime.fromisoformat(doc['ts']) except Exception: # Fallback in case of serialization issues - cached_time = datetime.strptime(doc['ts'], "%Y-%m-%d %H:%M:%S%z") + cached_time = datetime.strptime(doc['ts'], '%Y-%m-%d %H:%M:%S%z') age = now - cached_time if age < timedelta(hours=ttl): return doc['value'] @@ -48,7 +46,7 @@ def wrapper(*args, **kwargs): { 'subject_name': subject_name, 'function_name': func.__name__, - 'key': key, + 'key': str(key), }, { '$set': { @@ -56,8 +54,10 @@ def wrapper(*args, **kwargs): 'ts': now.replace(microsecond=0).isoformat(), } }, - upsert=True + upsert=True, ) return new_value + return wrapper + return decorator diff --git a/bughog/database/mongo/mongodb.py b/bughog/database/mongo/mongodb.py index ad0994be..0f41b28f 100644 --- a/bughog/database/mongo/mongodb.py +++ b/bughog/database/mongo/mongodb.py @@ -2,7 +2,7 @@ import logging from datetime import datetime, timezone -from typing import Optional +from typing import Any, Iterator, Optional from gridfs import GridFS from pymongo import ASCENDING, MongoClient @@ -14,9 +14,11 @@ from bughog.parameters import ( DatabaseParameters, EvaluationParameters, + ExperimentParameters, SubjectConfiguration, ) from bughog.version_control.state.base import ShallowState, State +from bughog.version_control.version import Version logger = logging.getLogger(__name__) @@ -139,23 +141,22 @@ def gridfs(self) -> GridFS: raise ServerException('Database server does not have a database') return GridFS(self._db) - def store_result(self, eval_params: EvaluationParameters, result: ExperimentResult): + def store_result(self, params: ExperimentParameters, result: ExperimentResult): """ Upserts the result. """ - subject_config = eval_params.subject_configuration - eval_params = eval_params - collection = self.__get_data_collection(eval_params) + subject_config = params.subject_configuration + collection = self.__get_data_collection(subject_config) query = { - 'subject_version': result.executable_version, + 'subject_version': str(result.executable_version) if result.executable_version else None, 'executable_origin': result.executable_origin, 'padded_subject_version': result.padded_subject_version, 'subject_config': subject_config.subject_setting, 'cli_options': subject_config.cli_options, 'extensions': subject_config.extensions, 'state': result.state, - 'project': eval_params.evaluation_range.project_name, - 'experiment': eval_params.evaluation_range.experiment_name, + 'project': params.project_name, + 'experiment': params.experiment_name, } # if browser_config.subject_name == 'firefox': # build_id = self.get_build_id_firefox(result.params.state) @@ -177,13 +178,13 @@ def store_result(self, eval_params: EvaluationParameters, result: ExperimentResu } collection.update_one(query, update, upsert=True) - def get_result(self, params: EvaluationParameters, state: ShallowState) -> Optional[ExperimentResult]: - collection = self.__get_data_collection(params) - query = self.__to_experiment_query(params, state) + def get_result(self, params: ExperimentParameters) -> Optional[ExperimentResult]: + collection = self.__get_data_collection(params.subject_configuration) + query = self.__to_experiment_query(params, params.state) doc = collection.find_one(query) if doc: return ExperimentResult( - doc['executable_version'], + Version(doc['subject_version']), doc['executable_origin'], doc['state'], doc['result']['raw'], @@ -191,12 +192,12 @@ def get_result(self, params: EvaluationParameters, state: ShallowState) -> Optio doc['dirty'], ) else: - logger.error(f'Could not find document for query {query}.') + logger.info(f'Could not find document for query {query}.') return None - def has_result(self, params: EvaluationParameters, state: ShallowState) -> bool: - collection = self.__get_data_collection(params) - query = self.__to_experiment_query(params, state) + def has_result(self, params: ExperimentParameters) -> bool: + collection = self.__get_data_collection(params.subject_configuration) + query = self.__to_experiment_query(params, params.state) nb_of_documents = collection.count_documents(query) return nb_of_documents > 0 @@ -205,12 +206,12 @@ def get_evaluated_states( params: EvaluationParameters, boundary_states: Optional[tuple[State, State]], dirty: Optional[bool] = None, - ) -> list[State]: - collection = self.__get_data_collection(params) - query = { - 'project': params.evaluation_range.project_name, + ) -> Iterator[dict]: + collection = self.__get_data_collection(params.subject_configuration) + query: dict[str, Any] = { + 'project': params.project_name, 'subject_config': params.subject_configuration.subject_setting, - 'experiment': params.evaluation_range.experiment_name, + 'experiment': params.experiment_name, 'result': {'$exists': True}, 'state.type': 'release' if params.evaluation_range.only_release_commits else 'commit', } @@ -236,22 +237,21 @@ def get_evaluated_states( if dirty is not None: query['dirty'] = dirty cursor = collection.find(query) - states = [] + for doc in cursor: - subject_type = params.subject_configuration.subject_type - subject_name = params.subject_configuration.subject_name - state = State.from_dict(subject_type, subject_name, doc['state']) - state.result_variables = set(tuple(item) for item in doc['result']['variables']) - state.result_attempt = doc['result'].get('attempt', 1) - states.append(state) - return states - - def __to_experiment_query(self, params: EvaluationParameters, state: ShallowState) -> dict: - state_query = {'state.' + k: v for k, v in state.dict.items()} - query = { - 'project': params.evaluation_range.project_name, + yield { + 'subject_type': params.subject_configuration.subject_type, + 'subject_name': params.subject_configuration.subject_name, + 'state': doc['state'], + 'result': doc['result'], + } + + def __to_experiment_query(self, params: ExperimentParameters, state: ShallowState) -> dict: + state_query = {'state.' + k: v for k, v in state.to_dict().items()} + query: dict[str, Any] = { + 'project': params.project_name, 'subject_config': params.subject_configuration.subject_setting, - 'experiment': params.evaluation_range.experiment_name, + 'experiment': params.experiment_name, } query.update(state_query) if len(params.subject_configuration.extensions) > 0: @@ -270,13 +270,11 @@ def __to_experiment_query(self, params: EvaluationParameters, state: ShallowStat query['cli_options'] = [] return query - def __get_data_collection(self, eval_params: EvaluationParameters) -> Collection: + def __get_data_collection(self, subject_config: SubjectConfiguration) -> Collection: """ Returns the data collection, of which the name is formatted as '{subject_type}_{subject_name}'. """ - collection_name = ( - f'{eval_params.subject_configuration.subject_type}_{eval_params.subject_configuration.subject_name}' - ) + collection_name = f'{subject_config.subject_type}_{subject_config.subject_name}' return self.get_collection(collection_name, create_if_not_found=True) def get_binary_availability_collection(self, subject_config: SubjectConfiguration) -> Collection: @@ -299,32 +297,38 @@ def get_stored_binary_availability(self, subject_config: SubjectConfiguration): return result def get_documents_for_plotting(self, params: EvaluationParameters, releases: bool = False) -> list: - collection = self.__get_data_collection(params) + collection = self.__get_data_collection(params.subject_configuration) evaluation_range = params.evaluation_range subject_config = params.subject_configuration - query = { - 'project': evaluation_range.project_name, - 'experiment': evaluation_range.experiment_name, - 'subject_config': subject_config.subject_setting, - 'state.type': 'release' if releases else 'commit', - 'extensions': {'$size': len(subject_config.extensions) if subject_config.extensions else 0}, - 'cli_options': {'$size': len(subject_config.cli_options) if subject_config.cli_options else 0}, + extensions_filter: dict[str, Any] = { + '$size': len(subject_config.extensions) if subject_config.extensions else 0 } if subject_config.extensions: - query['extensions']['$all'] = subject_config.extensions + extensions_filter['$all'] = subject_config.extensions + cli_options_filter: dict[str, Any] = { + '$size': len(subject_config.cli_options) if subject_config.cli_options else 0 + } if subject_config.cli_options: - query['cli_options']['$all'] = subject_config.cli_options + cli_options_filter['$all'] = subject_config.cli_options + query: dict[str, Any] = { + 'project': params.project_name, + 'experiment': params.experiment_name, + 'subject_config': subject_config.subject_setting, + 'state.type': 'release' if releases else 'commit', + 'extensions': extensions_filter, + 'cli_options': cli_options_filter, + } if evaluation_range.commit_nb_range: query['state.commit_nb'] = { '$gte': evaluation_range.commit_nb_range[0], '$lte': evaluation_range.commit_nb_range[1], } - elif evaluation_range.major_version_range: + elif evaluation_range.version_range: query['padded_subject_version'] = { - '$gte': str(evaluation_range.major_version_range[0]).zfill(4), - '$lte': str(evaluation_range.major_version_range[1] + 1).zfill(4), + '$gte': evaluation_range.version_range[0].padded(), + '$lte': evaluation_range.version_range[1].next_padded(), } docs = collection.aggregate( [ @@ -343,22 +347,22 @@ def get_documents_for_plotting(self, params: EvaluationParameters, releases: boo ) return list(docs) - def remove_datapoint(self, params: EvaluationParameters, state: ShallowState) -> None: - collection = self.__get_data_collection(params) - query = self.__to_experiment_query(params, state) + def remove_datapoint(self, params: ExperimentParameters) -> None: + collection = self.__get_data_collection(params.subject_configuration) + query = self.__to_experiment_query(params, params.state) count = collection.delete_one(query) if count.deleted_count == 0: - logger.error(f'Could not remove datapoint for {state}.') + logger.debug(f'Could not remove datapoint for {params.state} because it was not found.') else: - logger.debug(f'Removed datapoint for {state}.') + logger.debug(f'Removed datapoint for {params.state}.') def remove_all_data_for(self, params_list: list[EvaluationParameters]) -> None: for params in params_list: - collection = self.__get_data_collection(params) + collection = self.__get_data_collection(params.subject_configuration) collection.delete_many( { - 'project': params.evaluation_range.project_name, - 'experiment': params.evaluation_range.experiment_name, + 'project': params.project_name, + 'experiment': params.experiment_name, } ) diff --git a/bughog/distribution/worker_manager.py b/bughog/distribution/worker_manager.py index 4a8869aa..325a5a4e 100644 --- a/bughog/distribution/worker_manager.py +++ b/bughog/distribution/worker_manager.py @@ -2,13 +2,13 @@ import os import threading import time -from queue import Queue +from queue import Empty, Queue import docker import docker.errors -from bughog import configuration, worker -from bughog.parameters import EvaluationParameters +from bughog import config +from bughog.parameters import ExperimentParameters from bughog.version_control.state.base import State from bughog.web.clients import Clients @@ -16,32 +16,26 @@ class WorkerManager: - def __init__(self, eval_params: EvaluationParameters) -> None: - self.max_nb_of_containers = eval_params.sequence_configuration.nb_of_containers - - if self.max_nb_of_containers == 1: - logger.info('Running in single container mode') - else: - self.container_id_pool = Queue(maxsize=self.max_nb_of_containers) - for i in range(self.max_nb_of_containers): - self.container_id_pool.put(i) - self.client = docker.from_env() - subject_type = eval_params.subject_configuration.subject_type - subject_name = eval_params.subject_configuration.subject_name - self.worker_image_ref = self.__get_worker_image_ref(subject_type, subject_name) - - def start_experiment(self, params: EvaluationParameters, state: State, blocking_wait=True) -> None: - if self.max_nb_of_containers != 1: - return self.__run_container(params, state, blocking_wait) - - # Single container mode - worker.run(params, state) - Clients.push_results_to_all() - - def __run_container(self, params: EvaluationParameters, state: State, blocking_wait=True) -> None: - while blocking_wait and self.get_nb_of_running_worker_containers() >= self.max_nb_of_containers: - time.sleep(1) - container_id = self.container_id_pool.get() + def __init__(self, subject_type: str, subject_name: str, max_nb_of_containers: int) -> None: + self.max_nb_of_containers = max_nb_of_containers + + self.container_id_pool = Queue(maxsize=self.max_nb_of_containers) + for i in range(self.max_nb_of_containers): + self.container_id_pool.put(i) + self.client = docker.from_env() + self.worker_image_ref = self.__get_worker_image_ref(subject_type, subject_name) + + def start_experiment(self, params: ExperimentParameters, state: State, blocking_wait=True) -> None: + return self.__run_container(params, state, blocking_wait) + + def __run_container(self, params: ExperimentParameters, state: State, blocking_wait=True) -> None: + try: + container_id = self.container_id_pool.get(block=blocking_wait) + except Empty: + logger.warning( + 'No container id available to run experiment. This should not happen when blocking_wait is True.' + ) + return container_name = f'bh_worker_{container_id}' def start_container_thread(): @@ -69,8 +63,30 @@ def start_container_thread(): except docker.errors.APIError: logger.error('Could not consult list of active containers', exc_info=True) + core_in_development_mode = bool(os.getenv('DEVELOPMENT')) + worker_in_debug_mode = core_in_development_mode and self.max_nb_of_containers == 1 container = None try: + volumes = [ + os.path.join(host_pwd, '.devcontainer') + ':/app/.devcontainer:ro', + os.path.join(host_pwd, '.vscode') + ':/app/.vscode:ro', + os.path.join(host_pwd, 'config') + ':/app/config:ro', + os.path.join(host_pwd, 'subject') + ':/app/subject:rw', + os.path.join(host_pwd, 'logs') + ':/app/logs:rw', + os.path.join(host_pwd, 'nginx/ssl') + ':/etc/nginx/ssl:ro', + ] + if core_in_development_mode: + volumes.append(os.path.join(host_pwd, 'bughog') + ':/app/bughog:rw') + + debug_kwargs = {} + if worker_in_debug_mode: + logger.info(f"Starting container '{container_name}' in debug mode") + debug_kwargs['ports'] = {'5678/tcp': 5678} + debug_kwargs['environment'] = { + 'DEVELOPMENT': '1', + } + else: + logger.info(f"Starting container '{container_name}'") container = self.client.containers.run( self.worker_image_ref, name=container_name, @@ -80,13 +96,9 @@ def start_container_thread(): detach=True, labels=['bh_worker'], command=[params.serialize(), state.serialize()], - volumes=[ - os.path.join(host_pwd, 'config') + ':/app/config:ro', - os.path.join(host_pwd, 'subject') + ':/app/subject:rw', - os.path.join(host_pwd, 'logs') + ':/app/logs:rw', - os.path.join(host_pwd, 'nginx/ssl') + ':/etc/nginx/ssl:ro', - ], + volumes=volumes, tmpfs={'/memory': 'exec,size=3g,mode=1777'}, + **debug_kwargs, ) result = container.wait() if result['StatusCode'] != 0: @@ -122,10 +134,11 @@ def start_container_thread(): thread.start() logger.info(f"Container '{container_name}' started experiments for '{state}'") # Sleep to avoid all workers downloading executables at once, clogging up all IO. - time.sleep(.1) + time.sleep(0.1) - def get_nb_of_running_worker_containers(self): - return len(self.get_runnning_containers()) + @staticmethod + def get_nb_of_running_worker_containers(): + return len(WorkerManager.get_runnning_containers()) @staticmethod def get_runnning_containers(): @@ -134,12 +147,8 @@ def get_runnning_containers(): ) def wait_until_all_evaluations_are_done(self): - if self.max_nb_of_containers == 1: - return - while True: - if self.get_nb_of_running_worker_containers() == 0: - break - time.sleep(5) + while self.container_id_pool.qsize() < self.max_nb_of_containers: + time.sleep(1) @staticmethod def forcefully_stop_all_running_containers(): @@ -148,17 +157,21 @@ def forcefully_stop_all_running_containers(): def __get_worker_image_ref(self, subject_type: str, subject_name: str) -> str: """ - Returns the worker image's reference. + Returns the worker image reference to use for this subject. + Checks for a locally available subject-specific image first, then tries + to pull it. Falls back to the generic worker image if neither succeeds. + Subject-specific images are expected to be built beforehand (e.g. via + build-deploy.sh) and are never built at runtime. """ - subject_type_ref = f'bughog/worker-{subject_type}:{configuration.get_tag()}' - if self.__pull_worker_image(subject_type_ref): - return subject_type_ref + tag = config.get_tag() + image_ref = f'bughog/worker-{subject_name}:{tag}' + worker_ref = f'bughog/worker:{tag}' - subject_name_ref = f'bughog/worker-{subject_name}:{configuration.get_tag()}' - if self.__pull_worker_image(subject_name_ref): - return subject_name_ref + if self.__image_exists_locally(image_ref) or self.__pull_worker_image(image_ref): + return image_ref - return f'bughog/worker:{configuration.get_tag()}' + logger.warning(f"Subject-specific image '{image_ref}' not found, falling back to '{worker_ref}'.") + return worker_ref def __pull_worker_image(self, image_ref: str) -> bool: try: @@ -168,3 +181,10 @@ def __pull_worker_image(self, image_ref: str) -> bool: return False except docker.errors.APIError: return False + + def __image_exists_locally(self, image_ref: str) -> bool: + try: + self.client.images.get(image_ref) + return True + except docker.errors.ImageNotFound: + return False diff --git a/bughog/evaluation/evaluation.py b/bughog/evaluation/evaluation.py index 7a335df1..f2e60beb 100644 --- a/bughog/evaluation/evaluation.py +++ b/bughog/evaluation/evaluation.py @@ -1,11 +1,12 @@ import logging import time +from bughog.config import settings from bughog.database.mongo.mongodb import MongoDB from bughog.evaluation.collectors.collector import Collector from bughog.evaluation.experiment_result import ExperimentResult from bughog.evaluation.interaction import Interaction -from bughog.parameters import EvaluationParameters +from bughog.parameters import ExperimentParameters from bughog.subject import factory from bughog.subject.executable import Executable, ExecutableStatus from bughog.subject.simulation import Simulation @@ -20,16 +21,16 @@ def __init__(self, subject_type: str): self.experiments = factory.create_experiments(subject_type) self.should_stop = False - def evaluate(self, params: EvaluationParameters, state: State, is_worker=False): - if MongoDB().has_result(params, state.to_shallow_state()): + def evaluate(self, params: ExperimentParameters, state: State, is_worker=False): + if MongoDB().has_result(params): logger.warning( - f"Experiment '{params.evaluation_range.experiment_name}' for '{state}' was already performed, skipping." + f"Experiment '{params.experiment_name}' for '{params.state}' was already performed, skipping." ) return - subject = factory.get_subject_from_params(params) + subject = factory.get_subject_from_params(params.subject_configuration) - experiment_folder = self.experiments.get_experiment_folder(params) + experiment_folder = self.experiments.get_experiment_folder(params.project_name, params.experiment_name) executable = subject.create_executable(params.subject_configuration, state) runtime_flags = self.experiments.framework.get_runtime_flags(experiment_folder) runtime_env_vars = self.experiments.framework.get_runtime_env_vars(experiment_folder) @@ -66,7 +67,7 @@ def conduct_experiment( self, executable: Executable, simulation: Simulation, collector: Collector, script: list[str] ) -> ExperimentResult: is_dirty = False - tries_left = 3 + tries = 0 collector.start() poc_was_reproduced = False intermediary_variables = None @@ -74,8 +75,8 @@ def conduct_experiment( # Perform experiment with retries logger.info(f'Starting experiment for {executable.state}.') start_time = time.time() - while not poc_was_reproduced and tries_left > 0: - tries_left -= 1 + while not poc_was_reproduced and tries < settings.experiment_tries: + tries += 1 executable.pre_try_setup() try: Interaction(script).do_experiment(simulation) @@ -106,7 +107,7 @@ def conduct_experiment( result_variables.update(sanity_check_variables) elapsed_time = time.time() - start_time - logger.info(f'Experiment for {executable.state} finished in {elapsed_time:.2f}s with {tries_left} tries left.') + logger.info(f'Experiment for {executable.state} finished in {elapsed_time:.2f}s after {tries} tries.') return ExperimentResult( executable.version, executable.origin, executable.state.to_dict(), raw_results, result_variables, is_dirty ) diff --git a/bughog/evaluation/experiment_result.py b/bughog/evaluation/experiment_result.py index 706b6b6b..75cdc82f 100644 --- a/bughog/evaluation/experiment_result.py +++ b/bughog/evaluation/experiment_result.py @@ -1,11 +1,12 @@ from dataclasses import dataclass -from typing import Optional + +from bughog.version_control.version import Version @dataclass(frozen=True) class ExperimentResult: - executable_version: Optional[str] - executable_origin: Optional[str] + executable_version: Version | None + executable_origin: str | None state: dict raw_results: dict result_variables: set[tuple[str, str]] @@ -16,7 +17,7 @@ def is_reproduced(self) -> bool: return self.poc_is_reproduced(self.result_variables) @staticmethod - def poc_is_reproduced(result_variables: Optional[set[tuple[str,str]]]) -> bool: + def poc_is_reproduced(result_variables: set[tuple[str, str]] | None) -> bool: if result_variables is None: return False for key, value in result_variables: @@ -25,7 +26,7 @@ def poc_is_reproduced(result_variables: Optional[set[tuple[str,str]]]) -> bool: return False @staticmethod - def poc_passed_sanity_check(result_variables: Optional[set[tuple[str,str]]]) -> bool: + def poc_passed_sanity_check(result_variables: set[tuple[str, str]] | None) -> bool: if result_variables is None: return False for key, value in result_variables: @@ -34,7 +35,7 @@ def poc_passed_sanity_check(result_variables: Optional[set[tuple[str,str]]]) -> return False @staticmethod - def poc_is_dirty(result_variables: Optional[set[tuple[str,str]]]) -> bool: + def poc_is_dirty(result_variables: set[tuple[str, str]] | None) -> bool: """ Returns whether the poc is dirty: it is not reproduced and the sanity check did not succeed. """ @@ -43,17 +44,21 @@ def poc_is_dirty(result_variables: Optional[set[tuple[str,str]]]) -> bool: return not reproduced and not sanity_check_succeeded @property - def padded_subject_version(self) -> Optional[str]: + def padded_subject_version(self) -> str: """ - Pads the executable's version. - Returns None if padding fails. + Returns a zero-padded version string derived from the executable's version, + suitable for lexicographic comparison. """ if self.executable_version is None: - return None - padding_target = 4 - padded_version = [] - for sub in self.executable_version.split('.'): - if len(sub) > padding_target: - return None - padded_version.append('0' * (padding_target - len(sub)) + sub) - return '.'.join(padded_version) + raise ValueError('executable_version is None') + return self.executable_version.padded() + + def to_dict(self) -> dict: + return { + 'executable_version': str(self.executable_version) if self.executable_version else None, + 'executable_origin': self.executable_origin, + 'state': self.state, + 'raw_results': self.raw_results, + 'result_variables': list(self.result_variables), + 'is_dirty': self.is_dirty, + } diff --git a/bughog/evaluation/experiments.py b/bughog/evaluation/experiments.py index 1016c509..72d13ea3 100644 --- a/bughog/evaluation/experiments.py +++ b/bughog/evaluation/experiments.py @@ -3,7 +3,6 @@ import shutil from bughog.evaluation.file_structure import Folder -from bughog.parameters import EvaluationParameters from bughog.subject.evaluation_framework import EvaluationFramework logger = logging.getLogger(__name__) @@ -104,7 +103,9 @@ def add_folder_or_file(self, project: str, poc: str, folder_name: str | None, fi poc_folder = project_folder.get_folder(poc) if folder_name is not None: if poc_folder.file_exists(folder_name): - raise Exception(f'Could not create {folder_name} in {poc_folder.path}, because a file with the same name exists.') + raise Exception( + f'Could not create {folder_name} in {poc_folder.path}, because a file with the same name exists.' + ) elif poc_folder.folder_exists(folder_name): folder = poc_folder.get_folder(folder_name) else: @@ -157,9 +158,7 @@ def __get_experiment_folder(self, project_name: str, experiment_name: str) -> Fo return experiment raise Exception(f"Could not find experiment '{experiment_name}'") - def get_experiment_folder(self, params: EvaluationParameters) -> Folder: - project_name = params.evaluation_range.project_name - experiment_name = params.evaluation_range.experiment_name + def get_experiment_folder(self, project_name: str, experiment_name: str) -> Folder: return self.__get_experiment_folder(project_name, experiment_name) def get_experiment_dir_tree(self, project_name: str, experiment_name: str) -> dict: diff --git a/bughog/evaluation/file_structure.py b/bughog/evaluation/file_structure.py index 2e2bb606..4dcf6f73 100644 --- a/bughog/evaluation/file_structure.py +++ b/bughog/evaluation/file_structure.py @@ -19,7 +19,7 @@ def file_type(self) -> str | None: return self.name.split('.')[-1] @property - def comment_delimiters(self) -> tuple[str,str|None] | None: + def comment_delimiters(self) -> tuple[str, str | None] | None: match self.file_type: case 'html' | 'xml': return r'' @@ -41,7 +41,7 @@ def get_bughog_poc_parameter(self, name: str) -> str | None: # Stop looking upon the first non-comment line that also is not the document declaration. if not re.match(rf'^\s*{prefix}', line) and ' Folder: def get_file(self, name: str) -> File: matched = [file for file in self.files if file.name == name] if len(matched) == 0: - raise Exception(f'Could not find {name} in {self.path}.') + raise FileNotFoundError(f'Could not find {name} in {self.path}.') return matched[0] def create_file(self, name: str, content: bytes): @@ -107,7 +103,7 @@ def create_file(self, name: str, content: bytes): def get_folder(self, name: str) -> Folder: matched = [file for file in self.subfolders if file.name == name] if len(matched) == 0: - raise Exception(f'Could not find folder {name}.') + raise FileNotFoundError(f'Could not find folder {name} in {self.path}.') return matched[0] def create_folder(self, name: str) -> Folder: @@ -148,7 +144,9 @@ def __can_create(self, name: str) -> None: raise AttributeError('The file name cannot be empty.') regex = r'^[A-Za-z0-9_\-.]+$' if re.match(regex, name) is None: - raise AttributeError(f"The given name '{name}' is invalid. Only letters, numbers, '.', '-' and '_' can be used, and the name should not be empty.") + raise AttributeError( + f"The given name '{name}' is invalid. Only letters, numbers, '.', '-' and '_' can be used, and the name should not be empty." + ) def __repr__(self): return f'Folder(name={self.name}, path={self.path}, subfolders={self.subfolders}, files={self.files})' diff --git a/bughog/evaluation/interaction.py b/bughog/evaluation/interaction.py index 3bb10eca..e638f739 100644 --- a/bughog/evaluation/interaction.py +++ b/bughog/evaluation/interaction.py @@ -44,9 +44,11 @@ def _interpret(self, simulation: Simulation) -> bool: return True except SimulationException as e: # Simulation exception - sane behaviour, but do not continue interpreting + logger.exception(e) simulation.report_simulation_error(str(e)) return True except Exception as e: # Unexpected exception type - not sane, report the exception + logger.exception(e) simulation.report_simulation_error(str(e)) return False diff --git a/bughog/integration_tests/evaluation_configurations.py b/bughog/integration_tests/evaluation_configurations.py index 1fe125d0..cfe73f20 100644 --- a/bughog/integration_tests/evaluation_configurations.py +++ b/bughog/integration_tests/evaluation_configurations.py @@ -1,6 +1,6 @@ import os -from bughog import configuration +from bughog import config from bughog.integration_tests import verify_results from bughog.parameters import ( EvaluationParameters, @@ -9,6 +9,7 @@ SubjectConfiguration, ) from bughog.subject import factory +from bughog.version_control.version import Version def get_default_configuration(subject_type: str, subject_name: str) -> SubjectConfiguration: @@ -24,11 +25,18 @@ def get_default_configuration(subject_type: str, subject_name: str) -> SubjectCo def get_default_evaluation_range( subject_type: str, subject_name: str, experiment: str, only_releases: bool ) -> EvaluationRange: - min_version, max_version = factory.get_subject_availability(subject_type, subject_name) + subject_availability = factory.get_subject_availability(subject_type, subject_name) + min_version = subject_availability['min_version'] + max_version = subject_availability['max_version'] + versions = subject_availability['available_versions'] + + assert isinstance(min_version, (str, int)) + assert isinstance(max_version, (str, int)) + assert isinstance(versions, list) + return EvaluationRange( - verify_results.TEST_PROJECT_NAME, - experiment, - (min_version, max_version), + (Version(min_version), Version(max_version)), + [Version(v) for v in versions], None, only_releases, ) @@ -46,8 +54,10 @@ def get_default_sequence_config(sequence_limit: int) -> SequenceConfiguration: def get_default_evaluation_parameters( subject_type: str, subject_name: str, experiment: str, sequence_limit: int = 100, only_releases: bool = True ) -> EvaluationParameters: - database_params = configuration.get_database_params() + database_params = config.get_database_params() return EvaluationParameters( + verify_results.TEST_PROJECT_NAME, + experiment, get_default_configuration(subject_type, subject_name), get_default_evaluation_range(subject_type, subject_name, experiment, only_releases), get_default_sequence_config(sequence_limit), diff --git a/bughog/integration_tests/verify_results.py b/bughog/integration_tests/verify_results.py index ea067914..3a117de1 100644 --- a/bughog/integration_tests/verify_results.py +++ b/bughog/integration_tests/verify_results.py @@ -3,15 +3,16 @@ from collections import defaultdict from typing import Callable, Generator -from bughog.database.mongo.mongodb import MongoDB from bughog.evaluation.experiment_result import ExperimentResult from bughog.evaluation.experiments import Experiments from bughog.evaluation.file_structure import Folder +from bughog.exceptions import UserError from bughog.integration_tests import evaluation_configurations from bughog.parameters import EvaluationParameters from bughog.subject import factory from bughog.subject.evaluation_framework import EvaluationFramework from bughog.version_control.state.base import State +from bughog.version_control.state_factory import create_state_factory TEST_PROJECT_NAME = '_tests' logger = logging.getLogger(__name__) @@ -23,7 +24,7 @@ def get_all_testable_subject_types() -> Generator[str]: if TEST_PROJECT_NAME in all_experiments.get_projects(): yield subject_type else: - logger.warning(f'Skipping {subject_type} testing, because no "{TEST_PROJECT_NAME} was found.') + logger.warning(f'Skipping {subject_type} testing, because no "{TEST_PROJECT_NAME}" was found.') def verify_all() -> dict: @@ -35,33 +36,43 @@ def verify_all() -> dict: eval_parameters_list = evaluation_configurations.get_eval_parameters_list(subject_type, elegible_experiments) for eval_parameters in eval_parameters_list: - experiment_verification = __verify_experiment(eval_parameters, all_experiments) - if experiment_verification: - grouped_results[subject_type].append(experiment_verification) + try: + experiment_verification = __verify_experiment(eval_parameters, all_experiments) + if experiment_verification: + grouped_results[subject_type].append(experiment_verification) + except UserError as e: + logger.error( + f'UserError while verifying {eval_parameters.experiment_name} of {subject_type}.', exc_info=e + ) # Return list of lists, each sublist for one subject_type return grouped_results -def __verify_experiment(eval_parameters: EvaluationParameters, all_experiments: Experiments) -> dict | None: - experiment_name = eval_parameters.evaluation_range.experiment_name - experiment_folder = all_experiments.get_experiment_folder(eval_parameters) +def __verify_experiment(params: EvaluationParameters, all_experiments: Experiments) -> dict | None: + experiment_name = params.experiment_name + experiment_folder = all_experiments.get_experiment_folder(params.project_name, experiment_name) verification_func = __get_verification_function(all_experiments.framework, experiment_folder) if verification_func is None: return None - states = MongoDB().get_evaluated_states(eval_parameters, None) + state_factory = create_state_factory(params) + states = state_factory.create_evaluated_states() nb_of_success_results = len(list(filter(lambda x: verification_func(x), states))) - nb_of_fail_results = len(list(filter(lambda x: not verification_func(x) and not ExperimentResult.poc_is_dirty(x.result_variables), states))) + nb_of_fail_results = len( + list( + filter(lambda x: not verification_func(x) and not ExperimentResult.poc_is_dirty(x.result_variables), states) + ) + ) nb_of_error_results = len(list(filter(lambda x: ExperimentResult.poc_is_dirty(x.result_variables), states))) nb_of_results = nb_of_success_results + nb_of_fail_results + nb_of_error_results success_ratio = 0 if nb_of_results == 0 else round((nb_of_success_results / nb_of_results) * 100) return { 'experiment_name': experiment_name, - 'subject_type': eval_parameters.subject_configuration.subject_type, - 'subject_name': eval_parameters.subject_configuration.subject_name, + 'subject_type': params.subject_configuration.subject_type, + 'subject_name': params.subject_configuration.subject_name, 'nb_of_success_results': nb_of_success_results, 'nb_of_fail_results': nb_of_fail_results, 'nb_of_error_results': nb_of_error_results, @@ -85,14 +96,14 @@ def __get_verification_function(eval_framework: EvaluationFramework, experiment_ case _: if type(param_value) is str: reproducing_ranges = ast.literal_eval(param_value) - if type(reproducing_ranges) is list[tuple[int,int]]: + if type(reproducing_ranges) is list[tuple[int, int]]: return __create_complex_verification_function(reproducing_ranges) logger.warning(f'Skipping {experiment_folder.name}, because could not parse given "{param_name}".') return None -def __create_complex_verification_function(reproducing_ranges: list[tuple[int,int]]) -> Callable: +def __create_complex_verification_function(reproducing_ranges: list[tuple[int, int]]) -> Callable: def verification_function(state: State): for start, end in reproducing_ranges: if start <= state.index <= end: diff --git a/bughog/main.py b/bughog/main.py index 749df886..813e43ba 100644 --- a/bughog/main.py +++ b/bughog/main.py @@ -1,9 +1,8 @@ import logging -import os import time import bughog.database.mongo.container as mongodb_container -from bughog import configuration +from bughog import config from bughog.database.mongo.executable_cache import ExecutableCache from bughog.database.mongo.mongodb import MongoDB, ServerException from bughog.distribution.worker_manager import WorkerManager @@ -11,14 +10,14 @@ from bughog.parameters import ( DatabaseParameters, EvaluationParameters, + ExperimentParameters, ) from bughog.search_strategy.bgb_search import BiggestGapBisectionSearch from bughog.search_strategy.bgb_sequence import BiggestGapBisectionSequence from bughog.search_strategy.composite_search import CompositeSearch from bughog.search_strategy.sequence_strategy import SequenceFinished, SequenceStrategy from bughog.subject import factory -from bughog.version_control.state.base import ShallowState -from bughog.version_control.state_factory import StateFactory +from bughog.version_control.state_factory import create_state_factory, create_state_from_shallow_state from bughog.web.clients import Clients logger = logging.getLogger(__name__) @@ -33,14 +32,16 @@ def __init__(self) -> None: self.eval_queue = [] - self.db_connection_params = configuration.get_database_params() + self.db_connection_params = config.get_database_params() self.connect_to_database(self.db_connection_params) factory.initialize_all_subject_folders() + # Preload all subject availability to speed up the UI (bughog service calls are cached) + factory.get_all_subject_availability() logger.info('BugHog is ready!') - if os.getenv('GITHUB_TOKEN') is None: + if config.settings.github_token is None: logger.warning( - 'GITHUB_TOKEN was not configured in ./config/.env. This might result in failed API requests.' + 'BUGHOG_GITHUB_TOKEN was not configured in ./config/.env. This might result in failed API requests.' ) def connect_to_database(self, db_connection_params: DatabaseParameters) -> None: @@ -50,37 +51,48 @@ def connect_to_database(self, db_connection_params: DatabaseParameters) -> None: logger.error('Could not connect to database.', exc_info=True) def run(self, eval_params_list: list[EvaluationParameters]) -> None: - # Sequence_configuration settings are the same over evaluation parameters (quick fix) self.__update_state(is_running=True, reason='user', status='running') - worker_manager = WorkerManager(eval_params_list[0]) self.stop_gracefully = False self.stop_forcefully = False + + # Group evaluations by subject so each gets its own correctly-configured worker image. + groups: dict[tuple[str, str], list[EvaluationParameters]] = {} + for params in eval_params_list: + key = (params.subject_configuration.subject_type, params.subject_configuration.subject_name) + groups.setdefault(key, []).append(params) + try: self.__init_eval_queue(eval_params_list) - for eval_params in eval_params_list: + for (subject_type, subject_name), group in groups.items(): if self.stop_gracefully or self.stop_forcefully: break - self.__update_eval_queue(eval_params.evaluation_range.experiment_name, 'active') - self.__update_state( - is_running=True, - reason='user', - status='running', - queue=self.eval_queue, + worker_manager = WorkerManager( + subject_type, subject_name, group[0].sequence_configuration.nb_of_containers ) try: - self.run_single_evaluation(eval_params, worker_manager) - except (UserError, SystemError) as e: - # If we are running integration tests, we want to just continue with other subjects. - unique_subjects = set( - [eval_params.subject_configuration.subject_name for eval_params in eval_params_list] - ) - if len(unique_subjects) == 1: - raise e - except Exception: - logger.error( - f'Could not finish evaluation for {eval_params.subject_configuration.subject_name}.', - exc_info=True, - ) + for eval_params in group: + if self.stop_gracefully or self.stop_forcefully: + break + self.__update_eval_queue(eval_params.experiment_name, 'active') + self.__update_state( + is_running=True, + reason='user', + status='running', + queue=self.eval_queue, + ) + try: + self.run_single_evaluation(eval_params, worker_manager) + except (UserError, SystemError) as e: + # For a single subject, surface the error. For multiple subjects + # (e.g. integration tests), log and continue with the next subject. + if len(groups) == 1: + raise e + logger.error(f'Skipping remaining evaluations for {subject_name} due to error: {e}') + break + except Exception: + logger.error(f'Could not finish evaluation for {subject_name}.', exc_info=True) + finally: + worker_manager.wait_until_all_evaluations_are_done() # Exit handling if self.stop_gracefully: @@ -89,7 +101,7 @@ def run(self, eval_params_list: list[EvaluationParameters]) -> None: elif self.stop_forcefully: logger.info('Forcefully stopping experiment queue due to user end signal...') self.state['reason'] = 'user' - worker_manager.forcefully_stop_all_running_containers() + WorkerManager.forcefully_stop_all_running_containers() else: logger.info('Gracefully stopping experiment queue since last experiment started.') @@ -100,8 +112,6 @@ def run(self, eval_params_list: list[EvaluationParameters]) -> None: logger.critical('A critical error occurred', exc_info=True) raise e finally: - logger.info('Waiting for remaining experiments to stop...') - worker_manager.wait_until_all_evaluations_are_done() logger.info('BugHog has finished the evaluation!') self.__update_state(is_running=False, status='idle', queue=self.eval_queue) @@ -111,8 +121,8 @@ def run_single_evaluation(self, eval_params: EvaluationParameters, worker_manage nb_of_iterations = 3 for i in range(1, nb_of_iterations + 1): start_time = time.time() - subject = factory.get_subject_from_params(eval_params) - experiment_name = eval_params.evaluation_range.experiment_name + subject = factory.get_subject_from_params(eval_params.subject_configuration) + experiment_name = eval_params.experiment_name search_strategy = self.create_sequence_strategy(eval_params) logger.info( @@ -125,7 +135,8 @@ def run_single_evaluation(self, eval_params: EvaluationParameters, worker_manage current_state = search_strategy.next(wait=False) # Start worker to perform evaluation - worker_manager.start_experiment(eval_params, current_state) + experiment_params = eval_params.to_experiment_parameters(current_state.to_shallow_state()) + worker_manager.start_experiment(experiment_params, current_state) except SequenceFinished: worker_manager.wait_until_all_evaluations_are_done() @@ -141,37 +152,57 @@ def run_single_evaluation(self, eval_params: EvaluationParameters, worker_manage worker_manager.wait_until_all_evaluations_are_done() self.state['reason'] = 'finished' - self.__update_eval_queue(eval_params.evaluation_range.experiment_name, 'done') + self.__update_eval_queue(eval_params.experiment_name, 'done') Clients.push_notification_to_all(f'Evaluation of {experiment_name} has finished.') def retry_dirty_tests(self, eval_params: EvaluationParameters, worker_manager: WorkerManager) -> None: - dirty_states = MongoDB().get_evaluated_states(eval_params, None, dirty=True) - if (nb_of_dirty_states := len(dirty_states)) == 0: + state_factory = create_state_factory(eval_params) + nb_of_dirty_states = 0 + for dirty_state in state_factory.create_evaluated_states(dirty=True): + if nb_of_dirty_states == 0: + experiment = eval_params.experiment_name + message = f'Retrying tests with a dirty result for {experiment}.' + logger.info(message) + Clients.push_notification_to_all(message) + nb_of_dirty_states += 1 + if self.stop_gracefully or self.stop_forcefully: + return + experiment_params = eval_params.to_experiment_parameters(dirty_state.to_shallow_state()) + MongoDB().remove_datapoint(experiment_params) + worker_manager.start_experiment(experiment_params, dirty_state) + + if nb_of_dirty_states == 0: logger.info('No tests are associated with a dirty result.') return - experiment = eval_params.evaluation_range.experiment_name - message = f'Retrying {nb_of_dirty_states} tests with a dirty result for {experiment}.' - logger.info(message) - - Clients.push_notification_to_all(message) - for dirty_state in dirty_states: - if self.stop_gracefully or self.stop_forcefully: - return - MongoDB().remove_datapoint(eval_params, dirty_state.to_shallow_state()) - worker_manager.start_experiment(eval_params, dirty_state) worker_manager.wait_until_all_evaluations_are_done() + nb_after_retry = sum(1 for _ in state_factory.create_evaluated_states(dirty=True)) + logger.info(f'Dirty test results reduced from {nb_of_dirty_states} to {nb_after_retry}.') - dirty_states_after_retry = MongoDB().get_evaluated_states(eval_params, None, dirty=True) - logger.info(f'Dirty test results reduced from {nb_of_dirty_states} to {len(dirty_states_after_retry)}.') + def run_single_experiment(self, params: ExperimentParameters) -> None: + try: + self.__update_state(is_running=True, reason='user', status='running') + subject_config = params.subject_configuration + state = create_state_from_shallow_state( + params.state, subject_config.subject_type, subject_config.subject_name + ) + if not state.has_available_executable(): + Clients.push_notification_to_all(f'No available executable for state {state.commit_nb}.', type='error') + else: + worker_manager = WorkerManager(subject_config.subject_type, subject_config.subject_name, 1) + worker_manager.start_experiment(params, state) + worker_manager.wait_until_all_evaluations_are_done() + Clients.push_complete_experiment_result(params) + finally: + # TODO: error handling + self.__update_state(is_running=False, reason='idle', status='idle') @staticmethod - def create_sequence_strategy(eval_params: EvaluationParameters) -> SequenceStrategy: + def create_sequence_strategy(eval_params: EvaluationParameters) -> SequenceStrategy | CompositeSearch: sequence_config = eval_params.sequence_configuration search_strategy = sequence_config.search_strategy sequence_limit = sequence_config.sequence_limit - subject = factory.get_subject_from_params(eval_params) - state_factory = StateFactory(subject.state_oracle, eval_params) + state_factory = create_state_factory(eval_params) if search_strategy == 'bgb_sequence': strategy = BiggestGapBisectionSequence(state_factory, sequence_limit) @@ -224,13 +255,14 @@ def push_info(self, ws, *args) -> None: if arg == 'db_info' or all: update['db_info'] = MongoDB().get_info() if arg == 'logs' or all: - update['logs'] = configuration.Loggers.get_logs() + update['logs'] = config.Loggers.get_logs() if arg == 'state' or all: + self.state['nb_of_running_containers'] = WorkerManager.get_nb_of_running_worker_containers() update['state'] = self.state Clients.push_info(ws, update) - def remove_datapoint(self, params: EvaluationParameters, state: ShallowState) -> None: - MongoDB().remove_datapoint(params, state) + def remove_datapoint(self, params: ExperimentParameters) -> None: + MongoDB().remove_datapoint(params) Clients.push_results_to_all() def remove_cached_executable(self, subject_type: str, subject_name: str, state_name: str) -> None: @@ -239,6 +271,7 @@ def remove_cached_executable(self, subject_type: str, subject_name: str, state_n def __update_state(self, **kwargs) -> None: for key, value in kwargs.items(): self.state[key] = value + self.state['nb_of_running_containers'] = WorkerManager.get_nb_of_running_worker_containers() Clients.push_info_to_all({'state': self.state}) def __init_eval_queue(self, eval_params_list: list[EvaluationParameters]) -> None: @@ -246,7 +279,7 @@ def __init_eval_queue(self, eval_params_list: list[EvaluationParameters]) -> Non for eval_params in eval_params_list: self.eval_queue.append( { - 'experiment': eval_params.evaluation_range.experiment_name, + 'experiment': eval_params.experiment_name, 'state': 'pending', } ) diff --git a/bughog/parameters.py b/bughog/parameters.py index 54a3a7b4..847eedc1 100644 --- a/bughog/parameters.py +++ b/bughog/parameters.py @@ -7,19 +7,22 @@ from typing import Optional from bughog.exceptions import MissingParametersError +from bughog.version_control.state.base import ShallowState +from bughog.version_control.version import Version logger = logging.getLogger(__name__) @dataclass(frozen=True) -class EvaluationParameters: +class ExperimentParameters: """ - All parameters required to define an evaluation. + All parameters required to define an experiment. """ + project_name: str + experiment_name: str subject_configuration: SubjectConfiguration - evaluation_range: EvaluationRange - sequence_configuration: SequenceConfiguration + state: ShallowState database_params: DatabaseParameters def serialize(self) -> str: @@ -27,12 +30,33 @@ def serialize(self) -> str: return base64.b64encode(pickled_bytes).decode('ascii') @staticmethod - def deserialize(pickled_str: str) -> EvaluationParameters: + def deserialize(pickled_str: str) -> ExperimentParameters: pickled_bytes = base64.b64decode(pickled_str) return pickle.loads(pickled_bytes) + +@dataclass(frozen=True) +class EvaluationParameters: + """ + All parameters required to define an evaluation. + """ + + project_name: str + experiment_name: str + subject_configuration: SubjectConfiguration + evaluation_range: EvaluationRange + sequence_configuration: SequenceConfiguration + database_params: DatabaseParameters + + def to_experiment_parameters(self, state: ShallowState) -> ExperimentParameters: + return ExperimentParameters( + self.project_name, self.experiment_name, self.subject_configuration, state, self.database_params + ) + def to_plot_parameters(self, experiment_name: str, dirty_results_allowed: bool = True) -> PlotParameters: return PlotParameters( + self.project_name, + experiment_name, self.subject_configuration, self.evaluation_range, self.sequence_configuration, @@ -56,25 +80,64 @@ def to_dict(self) -> dict: @staticmethod def from_dict(data: dict) -> SubjectConfiguration: return SubjectConfiguration( - data['subject_type'], data['subject_name'], data['subject_setting'], data['cli_options'], data['extensions'] + data['subject_type'], + data['subject_name'], + data.get('subject_setting', 'default'), + data.get('cli_options', []), + data.get('extensions', []), ) @dataclass(frozen=True) class EvaluationRange: - project_name: str - experiment_name: str - major_version_range: tuple[int, int] | None = None + version_range: tuple[Version, Version] | None = None + versions: list[Version] | None = None commit_nb_range: tuple[int, int] | None = None only_release_commits: bool = False def __post_init__(self): - if self.major_version_range: - assert self.major_version_range[0] <= self.major_version_range[1] + if self.versions: + return + if self.version_range: + assert self.version_range[0] <= self.version_range[1] elif self.commit_nb_range: assert self.commit_nb_range[0] <= self.commit_nb_range[1] else: - raise AttributeError('Evaluation ranges require either major versions or commit numbers') + raise AttributeError( + 'Evaluation ranges require either major versions, commit numbers or a list of versions' + ) + + @staticmethod + def from_dict(data: dict) -> EvaluationRange: + return EvaluationRange( + EvaluationRange.__get_version_range(data), + EvaluationRange.__get_versions(data), + EvaluationRange.__get_commit_nb_range(data), + data.get('only_release_commits', False), + ) + + @staticmethod + def __get_versions(form_data: dict) -> list[Version] | None: + if versions := form_data.get('versions', None): + return [Version(str(v)) for v in versions] + return None + + @staticmethod + def __get_version_range(form_data: dict[str, str]) -> tuple[Version, Version] | None: + if range := form_data.get('version_range', None): + if len(range) == 2: + return (Version(str(range[0])), Version(str(range[1]))) + return None + + @staticmethod + def __get_commit_nb_range(form_data: dict[str, str]) -> tuple[int, int] | None: + lower_rev_number = form_data.get('lower_commit_nb', None) + upper_rev_number = form_data.get('upper_commit_nb', None) + lower_rev_number = int(lower_rev_number) if lower_rev_number else None + upper_rev_number = int(upper_rev_number) if upper_rev_number else None + if lower_rev_number is None or upper_rev_number is None: + return None + return (lower_rev_number, upper_rev_number) if lower_rev_number is not None else None @dataclass(frozen=True) @@ -83,6 +146,14 @@ class SequenceConfiguration: sequence_limit: int = 10000 search_strategy: str | None = None + @staticmethod + def from_dict(data: dict) -> SequenceConfiguration: + return SequenceConfiguration( + int(data.get('nb_of_containers', 1)), + int(data.get('sequence_limit', 50)), + data.get('search_strategy', None), + ) + @dataclass(frozen=True) class DatabaseParameters: @@ -116,42 +187,9 @@ def __repr__(self) -> str: class PlotParameters(EvaluationParameters): experiment: Optional[str] dirty_results_allowed: bool - # subject_name: Optional[str] - # database_collection: Optional[str] - # major_version_range: Optional[tuple[int, int]] = None - # revision_number_range: Optional[tuple[int, int]] = None - # subject_config: str = 'default' - # cli_options: Optional[list[str]] = None - # dirty_allowed: bool = True - - # @staticmethod - # def from_dict(data: dict) -> PlotParameters: - # if data.get('lower_version', None) and data.get('upper_version', None): - # major_version_range = (data['lower_version'], data['upper_version']) - # else: - # major_version_range = None - # if data.get('lower_revision_nb', None) and data.get('upper_revision_nb', None): - # revision_number_range = ( - # data['lower_revision_nb'], - # data['upper_revision_nb'], - # ) - # else: - # revision_number_range = None - # return PlotParameters( - # data.get('plot_experiment', None), - # data.get('target_mech_id', None), - # data.get('subject_name', None), - # data.get('db_collection', None), - # major_version_range=major_version_range, - # revision_number_range=revision_number_range, - # subject_config=data.get('subject_setting', 'default'), - # cli_options=data.get('cli_options', []), - # dirty_allowed=data.get('dirty_allowed', True), - # ) - - -@staticmethod -def evaluation_factory( + + +def create_evaluation_params( kwargs: dict, database_params: DatabaseParameters, only_to_plot=False ) -> list[EvaluationParameters]: experiments = set(x for x in kwargs.get('experiments', []) + [kwargs.get('experiment_to_plot')] if x is not None) @@ -159,23 +197,15 @@ def evaluation_factory( raise MissingParametersError() subject_configuration = SubjectConfiguration.from_dict(kwargs) - sequence_configuration = SequenceConfiguration( - int(kwargs.get('nb_of_containers', 1)), - int(kwargs.get('sequence_limit', 50)), - kwargs.get('search_strategy'), - ) + sequence_configuration = SequenceConfiguration.from_dict(kwargs) evaluation_params_list = [] for experiment in sorted(experiments): if only_to_plot and experiment != kwargs.get('experiment_to_plot'): continue - evaluation_range = EvaluationRange( + evaluation_range = EvaluationRange.from_dict(kwargs) + evaluation_params = EvaluationParameters( kwargs['project_name'], experiment, - __get_version_range(kwargs), - __get_commit_nb_range(kwargs), - kwargs.get('only_release_commits', False), - ) - evaluation_params = EvaluationParameters( subject_configuration, evaluation_range, sequence_configuration, @@ -185,29 +215,31 @@ def evaluation_factory( return evaluation_params_list -@staticmethod +def create_experiment_params(kwargs: dict, database_params: DatabaseParameters) -> ExperimentParameters: + subject_configuration = SubjectConfiguration.from_dict(kwargs) + if 'major_version' in kwargs: + state_type = 'release' + version = Version(str(kwargs['major_version'])) + elif 'commit_nb' in kwargs or 'commit_id' in kwargs: + state_type = 'commit' + version = None + else: + raise MissingParametersError( + 'Experiment parameters require either a major version, commit number, or commit id.' + ) + state = ShallowState( + state_type, + version, + kwargs.get('commit_nb'), + kwargs.get('commit_id'), + ) + poc_name = kwargs.get('experiment_to_plot', kwargs.get('poc_name')) + return ExperimentParameters(kwargs['project_name'], poc_name, subject_configuration, state, database_params) + + def __get_cookie_name(form_data: dict[str, str]) -> str | None: if form_data['check_for'] == 'request': return None if 'cookie_name' in form_data: return form_data['cookie_name'] return 'generic' - - -@staticmethod -def __get_version_range(form_data: dict[str, str]) -> tuple[int, int] | None: - if range := form_data.get('version_range', None): - if len(range) == 2: - return (int(range[0]), int(range[1])) - return None - - -@staticmethod -def __get_commit_nb_range(form_data: dict[str, str]) -> tuple[int, int] | None: - lower_rev_number = form_data.get('lower_commit_nb', None) - upper_rev_number = form_data.get('upper_commit_nb', None) - lower_rev_number = int(lower_rev_number) if lower_rev_number else None - upper_rev_number = int(upper_rev_number) if upper_rev_number else None - if lower_rev_number is None or upper_rev_number is None: - return None - return (lower_rev_number, upper_rev_number) if lower_rev_number is not None else None diff --git a/bughog/search_strategy/bgb_search.py b/bughog/search_strategy/bgb_search.py index 66514a21..4f06cc3a 100644 --- a/bughog/search_strategy/bgb_search.py +++ b/bughog/search_strategy/bgb_search.py @@ -118,7 +118,7 @@ def __create_pairs_between_clean_states(states: list[State]) -> list[tuple[State We assume there are no other clean states in this range. """ return[ - pair for pair in zip(states, states[1:]) + pair for pair in zip(states, states[1:], strict=False) if not (pair[0].has_dirty_result() and pair[1].has_dirty_result()) ] diff --git a/bughog/search_strategy/bgb_sequence.py b/bughog/search_strategy/bgb_sequence.py index ee118947..79f94c14 100644 --- a/bughog/search_strategy/bgb_sequence.py +++ b/bughog/search_strategy/bgb_sequence.py @@ -43,7 +43,7 @@ def next(self, wait=True) -> State: self._add_state(self._upper_state) return self._upper_state - pairs = list(zip(self._considered_states, self._considered_states[1:])) + pairs = list(zip(self._considered_states, self._considered_states[1:], strict=False)) while pairs: filtered_pairs = [pair for pair in pairs if not self._pair_is_in_unavailability_gap(pair)] furthest_pair = max(filtered_pairs, key=lambda x: x[1].index - x[0].index) diff --git a/bughog/search_strategy/sequence_strategy.py b/bughog/search_strategy/sequence_strategy.py index ff332f9f..182a4068 100644 --- a/bughog/search_strategy/sequence_strategy.py +++ b/bughog/search_strategy/sequence_strategy.py @@ -50,12 +50,12 @@ def _fetch_evaluated_states(self, wait=True) -> None: fetched_states = [] if wait: for _ in range(10): - fetched_states = self._state_factory.create_evaluated_states() + fetched_states = list(self._state_factory.create_evaluated_states()) if all(state in fetched_states for state in self._considered_states): break time.sleep(3) else: - fetched_states = self._state_factory.create_evaluated_states() + fetched_states = list(self._state_factory.create_evaluated_states()) for state in self._considered_states: if state not in fetched_states: diff --git a/bughog/subject/executable.py b/bughog/subject/executable.py index 52a3fe60..a3bda3fa 100644 --- a/bughog/subject/executable.py +++ b/bughog/subject/executable.py @@ -6,13 +6,13 @@ import time from abc import ABC, abstractmethod from enum import Enum, auto, unique -from typing import Optional -from bughog import util from bughog.evaluation.collectors.logs import LogCollector from bughog.evaluation.file_structure import Folder from bughog.parameters import SubjectConfiguration +from bughog.util import fs, http from bughog.version_control.state.base import State +from bughog.version_control.version import Version logger = logging.getLogger(__name__) @@ -32,10 +32,10 @@ def __init__(self, config: SubjectConfiguration, state: State) -> None: self._runtime_env_vars = {} self._runtime_args = [] self.__version = None - self.__process: Optional[subprocess.Popen] = None + self.__process: subprocess.Popen | None = None # # - # TO BE IMPLEMENT BY EVERY EVALUATION SUBJECT EXECUTABLE + # TO BE IMPLEMENTED BY EVERY EVALUATION SUBJECT EXECUTABLE # # @property @@ -121,7 +121,7 @@ def log_path(self) -> str: return LogCollector.log_path @property - @util.ensure_folder_exists + @fs.ensure_folder_exists def temporary_storage_folder(self) -> str: """ Executables are stored here before staging. @@ -134,7 +134,7 @@ def is_in_temporary_storage(self) -> bool: return os.path.isdir(path) and any(os.scandir(path)) @property - @util.ensure_folder_exists + @fs.ensure_folder_exists def staging_folder(self) -> str: return os.path.join('/memory/staging/', f'{self.config.subject_name}-{str(self.state.name)}') @@ -147,10 +147,11 @@ def is_ready_for_use(self) -> bool: return os.path.isfile(self.executable_path) and self.version is not None @property - def version(self) -> Optional[str]: + def version(self) -> Version | None: if self.__version is None: try: - self.__version = self._get_version() + version_str = self._get_version() + self.__version = Version(version_str) except Exception: logger.error(f'Could not retrieve version for {self.state}', exc_info=True) return None @@ -181,7 +182,7 @@ def fetch(self): self.origin = 'artisanal' logger.info(f'Executable from artisanal build for {self.state.name} was found.') executable_path = self.state.get_artisanal_executable_folder() - util.copy_folder(executable_path, self.temporary_storage_folder) + fs.copy_folder(executable_path, self.temporary_storage_folder) elif ExecutableCache.fetch_executable_files(self.config, self.state.name, self.temporary_storage_folder): self.origin = 'public' logger.info(f'Executable for {self.state.name} was fetched from cache.') @@ -189,7 +190,7 @@ def fetch(self): self.origin = 'public' start = time.time() executable_urls = self.state.get_executable_source_urls() - util.download_and_extract(executable_urls, self.temporary_storage_folder) + http.download_and_extract(executable_urls, self.temporary_storage_folder) elapsed_time = time.time() - start logger.info(f'Executable for {self.state.name} was downloaded in {elapsed_time:.2f}s') self._optimize_for_storage() @@ -203,7 +204,7 @@ def remove(self): def stage(self): self.unstage() - util.copy_folder(self.temporary_storage_folder, self.staging_folder) + fs.copy_folder(self.temporary_storage_folder, self.staging_folder) self._configure_executable() def unstage(self): @@ -212,19 +213,23 @@ def unstage(self): elif os.path.isdir(self.staging_folder): shutil.rmtree(self.staging_folder) - def run(self, experiment_specific_params: list[str], cwd: Optional[Folder] = None): + def run(self, experiment_specific_params: list[str], cwd: Folder | None = None): """ Runs the executable with the given arguments, and kills it after the given timeout. """ cli_command = self._get_cli_command() + experiment_specific_params + self._runtime_args logger.debug(f'Executing: {" ".join(cli_command)}') with open(self.log_path, 'a+') as file: - popen_args = {'args': cli_command, 'stdout': file, 'stderr': file, 'bufsize': 1, 'text': True} - if cwd: - popen_args['cwd'] = cwd.path - if self._runtime_env_vars: - popen_args['env'] = self._runtime_env_vars - self.__process = subprocess.Popen(**popen_args) + self.__process = subprocess.Popen( + cli_command, + stdout=file, + stderr=file, + bufsize=1, + text=True, + cwd=cwd.path if cwd else None, + env=self._runtime_env_vars if self._runtime_env_vars else None, + start_new_session=True, + ) def terminate(self, wait=False, timeout: int = 5): if self.__process is None: @@ -237,9 +242,11 @@ def terminate(self, wait=False, timeout: int = 5): try: self.__process.wait(timeout=timeout) except subprocess.TimeoutExpired: - logger.info(f'Subject process did not terminate after {timeout}s. Killing process through pkill...') - cli_command = self._get_cli_command() - subprocess.run(['pkill', '-2', cli_command[0].split('/')[-1]]) + logger.info(f'Subject process did not terminate after {timeout}s. Forcefully killing process group...') + try: + os.killpg(self.__process.pid, signal.SIGKILL) + except ProcessLookupError: + logger.debug(f'Process {self.__process.pid} already exited before SIGKILL could be sent.') self.__process.wait() logger.debug('Subject process terminated.') diff --git a/bughog/subject/factory.py b/bughog/subject/factory.py index 99f40412..c5184afe 100644 --- a/bughog/subject/factory.py +++ b/bughog/subject/factory.py @@ -1,8 +1,9 @@ import os from functools import lru_cache +from typing import Any from bughog.evaluation.experiments import Experiments -from bughog.parameters import EvaluationParameters +from bughog.parameters import SubjectConfiguration from bughog.subject.evaluation_framework import EvaluationFramework from bughog.subject.js_engine.evaluation_framework import JSEngineEvaluationFramework from bughog.subject.js_engine.v8.subject import V8Subject @@ -15,49 +16,32 @@ from bughog.subject.web_browser.chromium.subject import Chromium from bughog.subject.web_browser.evaluation_framework import BrowserEvaluationFramework from bughog.subject.web_browser.firefox.subject import Firefox +from bughog.subject.web_browser.servo.subject import Servo -subjects = { - 'js_engine': { - 'evaluation_framework': JSEngineEvaluationFramework, - 'subjects': [ - V8Subject(), - V8SandboxSubject() - ] - }, - 'wasm_runtime': { - 'evaluation_framework': WasmRuntimeEvaluationFramework, - 'subjects': [ - WasmtimeSubject() - ] - }, +subjects: dict[str, Any] = { + 'js_engine': {'evaluation_framework': JSEngineEvaluationFramework, 'subjects': [V8Subject(), V8SandboxSubject()]}, + 'wasm_runtime': {'evaluation_framework': WasmRuntimeEvaluationFramework, 'subjects': [WasmtimeSubject()]}, 'web_browser': { 'evaluation_framework': BrowserEvaluationFramework, - 'subjects': [ - Chromium(), - Firefox(), - ], + 'subjects': [Chromium(), Firefox(), Servo()], }, } -@staticmethod def get_all_subject_types() -> list[str]: return sorted(subjects.keys()) -@staticmethod def get_all_subjects_for(subject_type: str) -> list[Subject]: if subject_objects := subjects.get(subject_type): return subject_objects['subjects'] raise AttributeError(f"Subject type '{subject_type}' is not supported.") -@staticmethod def get_all_subject_names_for(subject_type: str) -> list[str]: return [subject.name for subject in get_all_subjects_for(subject_type)] -@staticmethod def create_evaluation_framework(subject_type: str) -> EvaluationFramework: if subject_classes := subjects.get(subject_type): return subject_classes['evaluation_framework'](subject_type) @@ -69,12 +53,10 @@ def create_experiments(subject_type: str) -> Experiments: return Experiments(subject_type, create_evaluation_framework(subject_type)) -@staticmethod def invalidate_experiment_cache(): create_experiments.cache_clear() -@staticmethod def get_all_subject_availability() -> list[dict]: subject_availability = [] for subject_type in get_all_subject_types(): @@ -85,20 +67,16 @@ def get_all_subject_availability() -> list[dict]: return subject_availability -@staticmethod -def get_subject_availability(subject_type: str, subject_name: str) -> tuple[int,int]: - subject_availability = get_subject(subject_type, subject_name).get_availability() - return subject_availability['min_version'], subject_availability['max_version'] +def get_subject_availability(subject_type: str, subject_name: str) -> dict[str, str | int | list[str]]: + return get_subject(subject_type, subject_name).get_availability() -@staticmethod -def get_subject_from_params(params: EvaluationParameters) -> Subject: - subject_type = params.subject_configuration.subject_type - subject_name = params.subject_configuration.subject_name +def get_subject_from_params(config: SubjectConfiguration) -> Subject: + subject_type = config.subject_type + subject_name = config.subject_name return get_subject(subject_type, subject_name) -@staticmethod def get_subject(subject_type: str, subject_name: str) -> Subject: subjects = get_all_subjects_for(subject_type) matched_subjects = [subject for subject in subjects if subject.name == subject_name] @@ -107,7 +85,6 @@ def get_subject(subject_type: str, subject_name: str) -> Subject: raise AttributeError(f"Subject '{subject_type}, {subject_name}' is not supported.") -@staticmethod def initialize_all_subject_folders() -> None: for subject_type, specs in subjects.items(): os.makedirs(f'/app/subject/{subject_type}/experiments/', exist_ok=True) diff --git a/bughog/subject/js_engine/subject.py b/bughog/subject/js_engine/subject.py index b2ee67ab..1326fd5e 100644 --- a/bughog/subject/js_engine/subject.py +++ b/bughog/subject/js_engine/subject.py @@ -1,7 +1,7 @@ from bughog.evaluation.collectors.collector import Collector from bughog.evaluation.collectors.logs import LogCollector from bughog.evaluation.file_structure import Folder -from bughog.parameters import EvaluationParameters +from bughog.parameters import ExperimentParameters from bughog.subject.executable import Executable from bughog.subject.js_engine.simulation import JSEngineSimulation from bughog.subject.subject import Subject @@ -13,7 +13,7 @@ def type(self) -> str: return 'js_engine' @staticmethod - def create_simulation(executable: Executable, context: Folder, params: EvaluationParameters) -> JSEngineSimulation: + def create_simulation(executable: Executable, context: Folder, params: ExperimentParameters) -> JSEngineSimulation: return JSEngineSimulation(executable, context, params) @staticmethod diff --git a/bughog/subject/js_engine/v8/state_oracle.py b/bughog/subject/js_engine/v8/state_oracle.py index 4a037cc6..cf468ea0 100644 --- a/bughog/subject/js_engine/v8/state_oracle.py +++ b/bughog/subject/js_engine/v8/state_oracle.py @@ -1,13 +1,13 @@ import logging import re -from typing import Literal import requests -from bughog import util from bughog.database.mongo.cache import Cache from bughog.subject.state_oracle import StateOracle +from bughog.util import http from bughog.version_control.conversion import bughog_service, github +from bughog.version_control.version import Version logger = logging.getLogger(__name__) @@ -32,10 +32,10 @@ def find_commit_id(self, commit_nb: int) -> str | None: return bughog_service.find_commit_id('v8', commit_nb) @Cache.cache_in_db('js_engine', 'v8') - def find_commit_of_release(self, release_version: int) -> tuple[int, str]: + def find_commit_of_release(self, release_version: Version) -> tuple[int, str]: # TODO: make more efficient (possibly by adding functionality to bughog service) all_release_tags = self.__get_all_release_tags() - major_release_tag = self._get_earliest_tag_with_major(all_release_tags, release_version) + major_release_tag = self._get_earliest_tag_version_match(all_release_tags, release_version) commit_id = github.find_commit_id_from_tag('v8', 'v8', major_release_tag) commit_nb = self.find_commit_nb(commit_id) return commit_nb, commit_id @@ -47,14 +47,25 @@ def get_commit_url(self, commit_nb: int, commit_id: str | None) -> str | None: # Public executables - def get_most_recent_major_release_version(self) -> int: + def get_earliest_supported_release_version(self) -> Version: + return Version('6') + + def get_latest_supported_release_version(self) -> Version: all_release_tags = self.__get_all_release_tags() - major_versions = set(int(tag.split('.')[0]) for tag in all_release_tags) - return max(major_versions) + versions = list(Version(tag.split('.')[0]) for tag in all_release_tags) + return max(versions) @Cache.cache_in_db('js_engine', 'v8') - def has_public_executable(self, state_index: int, state_type: Literal['release', 'commit']) -> bool: - for url in self.get_executable_download_urls(state_index, state_type): + def has_public_release_executable(self, version: Version) -> bool: + for url in self.get_release_executable_urls(version): + resp = requests.head(url, allow_redirects=True) + if resp.status_code == 200: + return True + return False + + @Cache.cache_in_db('js_engine', 'v8') + def has_public_commit_executable(self, commit_nb: int) -> bool: + for url in self.get_commit_executable_urls(commit_nb): resp = requests.head(url, allow_redirects=True) if resp.status_code == 200: return True @@ -66,25 +77,25 @@ def get_nearest_commit_with_executable( NotImplementedError() @Cache.cache_in_db('js_engine', 'v8') - def get_executable_download_urls(self, state_index: int, state_type: Literal['release', 'commit']) -> list[str]: - match state_type: - case 'release': - commit_nb = self.find_commit_of_release(state_index)[0] - return self.get_executable_download_urls(commit_nb, 'commit') - case 'commit': - # Debug: - return [ - f'https://www.googleapis.com/download/storage/v1/b/v8-asan/o/linux-debug%2Fasan-linux-debug-v8-component-{state_index}.zip?alt=media', - f'https://www.googleapis.com/download/storage/v1/b/v8-asan/o/linux-debug%2Fd8-asan-linux-debug-v8-component-{state_index}.zip?alt=media', - ] - # Release - # return [f'https://www.googleapis.com/download/storage/v1/b/v8-asan/o/linux-release%2Fd8-linux-release-v8-component-{state_index}.zip?alt=media'] + def get_release_executable_urls(self, version: Version) -> list[str]: + commit_nb = self.find_commit_of_release(version)[0] + return self.get_commit_executable_urls(commit_nb) + + @Cache.cache_in_db('js_engine', 'v8') + def get_commit_executable_urls(self, commit_nb: int) -> list[str]: + # Debug: + return [ + f'https://www.googleapis.com/download/storage/v1/b/v8-asan/o/linux-debug%2Fasan-linux-debug-v8-component-{commit_nb}.zip?alt=media', + f'https://www.googleapis.com/download/storage/v1/b/v8-asan/o/linux-debug%2Fd8-asan-linux-debug-v8-component-{commit_nb}.zip?alt=media', + ] + # Release + # return [f'https://www.googleapis.com/download/storage/v1/b/v8-asan/o/linux-release%2Fd8-linux-release-v8-component-{commit_nb}.zip?alt=media'] @staticmethod @Cache.cache_in_db('js_engine', 'v8', ttl=24) def __get_all_release_tags() -> list[str]: url = 'https://chromium.googlesource.com/v8/v8.git/+refs' - html = util.request_html(url).decode() + html = http.request_html(url).decode() all_tags = re.findall(r'/refs/tags/(\d+(?:\.\d+)+)', html) pattern = re.compile(r'^\d+\.\d+\.\d+$') return [tag for tag in all_tags if pattern.match(tag)] diff --git a/bughog/subject/js_engine/v8/subject.py b/bughog/subject/js_engine/v8/subject.py index edb47ff4..6641322f 100644 --- a/bughog/subject/js_engine/v8/subject.py +++ b/bughog/subject/js_engine/v8/subject.py @@ -6,24 +6,13 @@ class V8Subject(JsEngine): - @property def name(self) -> str: return 'v8' @property - def _state_oracle_class(self) -> type[V8StateOracle]: - return V8StateOracle - - def get_availability(self) -> dict: - """ - Returns availability data (minimum and maximu, release versions, and configuration options) of the subject. - """ - return { - 'name': 'v8', - 'min_version': 6, - 'max_version': self.state_oracle.get_most_recent_major_release_version() - } + def state_oracle(self) -> V8StateOracle: + return V8StateOracle(self.type, self.name) def create_executable(self, subject_configuration: SubjectConfiguration, state: State) -> V8Executable: return V8Executable(subject_configuration, state) diff --git a/bughog/subject/js_engine/v8_sandbox/state_oracle.py b/bughog/subject/js_engine/v8_sandbox/state_oracle.py index 962f8c73..aae0398d 100644 --- a/bughog/subject/js_engine/v8_sandbox/state_oracle.py +++ b/bughog/subject/js_engine/v8_sandbox/state_oracle.py @@ -1,7 +1,8 @@ import logging -from typing import Literal from bughog.subject.js_engine.v8.state_oracle import V8StateOracle +from bughog.version_control.conversion import bughog_service +from bughog.version_control.version import Version logger = logging.getLogger(__name__) @@ -19,8 +20,20 @@ def __init__(self, subject_type: str, subject_name: str) -> None: # There are no public executables, only artisanal. super().__init__(subject_type, subject_name, only_artisanal=True) - def has_public_executable(self, state_index: int, state_type: Literal['release', 'commit']) -> bool: + def get_most_recent_commit_nb(self) -> int: + """ + We override this method because we want to call the API for v8, not v8_sandbox. + """ + return bughog_service.find_latest_commit_info('v8')['nb'] + + def has_public_release_executable(self, version: Version) -> bool: + return False + + def has_public_commit_executable(self, commit_nb: int) -> bool: return False - def get_executable_download_urls(self, state_index: int, state_type: Literal['release', 'commit']) -> list[str]: + def get_release_executable_urls(self, version: Version) -> list[str]: + raise Exception('Only artisanal executables are available.') + + def get_commit_executable_urls(self, commit_nb: int) -> list[str]: raise Exception('Only artisanal executables are available.') diff --git a/bughog/subject/js_engine/v8_sandbox/subject.py b/bughog/subject/js_engine/v8_sandbox/subject.py index 1712817c..571d8a65 100644 --- a/bughog/subject/js_engine/v8_sandbox/subject.py +++ b/bughog/subject/js_engine/v8_sandbox/subject.py @@ -6,24 +6,13 @@ class V8SandboxSubject(JsEngine): - @property def name(self) -> str: return 'v8_sandbox' @property - def _state_oracle_class(self) -> type[V8SandboxStateOracle]: - return V8SandboxStateOracle - - def get_availability(self) -> dict: - """ - Returns availability data (minimum and maximu, release versions, and configuration options) of the subject. - """ - return { - 'name': self.name, - 'min_version': 6, - 'max_version': self.state_oracle.get_most_recent_major_release_version() - } + def state_oracle(self) -> V8SandboxStateOracle: + return V8SandboxStateOracle(self.type, self.name) def create_executable(self, subject_configuration: SubjectConfiguration, state: State) -> V8Executable: return V8Executable(subject_configuration, state) diff --git a/bughog/subject/simulation.py b/bughog/subject/simulation.py index c9822393..e6a9cdfa 100644 --- a/bughog/subject/simulation.py +++ b/bughog/subject/simulation.py @@ -2,12 +2,12 @@ from abc import ABC, abstractmethod from bughog.evaluation.file_structure import Folder -from bughog.parameters import EvaluationParameters +from bughog.parameters import ExperimentParameters from bughog.subject.executable import Executable class Simulation(ABC): - def __init__(self, executable: Executable, context: Folder, params: EvaluationParameters) -> None: + def __init__(self, executable: Executable, context: Folder, params: ExperimentParameters) -> None: self.executable = executable self.context = context self.params = params diff --git a/bughog/subject/state_oracle.py b/bughog/subject/state_oracle.py index 7418c866..c9e9a83e 100644 --- a/bughog/subject/state_oracle.py +++ b/bughog/subject/state_oracle.py @@ -4,6 +4,7 @@ from bughog.subject.artisanal_executable_manager import artisanal_executable_manager from bughog.version_control.conversion import bughog_service +from bughog.version_control.version import Version class StateOracle(ABC): @@ -22,18 +23,33 @@ def find_commit_nb(self, commit_id: str) -> int: def find_commit_id(self, commit_nb: int) -> str | None: pass - @abstractmethod - def find_commit_of_release(self, release_version: int) -> tuple[int, str]: - pass + def find_commit_of_release(self, release_version: Version) -> tuple[int, str]: + version_info = bughog_service.find_version_info(self.subject_name, release_version) + return version_info.get('commit_info', {}).get('nb'), version_info.get('commit_info', {}).get('id') @abstractmethod def get_commit_url(self, commit_nb: int, commit_id: str | None) -> str | None: pass @abstractmethod - def get_most_recent_major_release_version(self) -> int: + def get_earliest_supported_release_version(self) -> Version: pass + def get_latest_supported_release_version(self) -> Version: + return bughog_service.find_latest_major_version(self.subject_name) + + def get_all_available_release_versions(self) -> list[Version]: + versions = [ + Version(v) + for version_info in bughog_service.find_all_versions(self.subject_name) + if (v := version_info.get('version')) is not None + ] + versions.sort() + return versions + + def get_most_recent_commit_nb(self) -> int: + return bughog_service.find_latest_commit_info(self.subject_name)['nb'] + @staticmethod def is_valid_commit_id(commit_id: str) -> bool: """ @@ -51,9 +67,12 @@ def is_valid_commit_nb(commit_nb: int) -> bool: return re.match(r'[0-9]{1,7}', str(commit_nb)) is not None @staticmethod - def get_full_version_from_release_tag(release_tag: str) -> str | None: - if match := re.search(r'\d+\.\d+\.\d+', release_tag): - return match[0] + def get_full_version_from_release_tag(release_tag: str) -> Version | None: + if match := re.search(r'\d+\.\d+(?:\.\d+)*(?:-\w+)?', release_tag): + try: + return Version(match[0]) + except Exception: + return None return None """ @@ -85,12 +104,19 @@ def get_nearest_state_with_executable( # Public executables + def has_public_release_executable(self, version: Version) -> bool: + return bughog_service.find_version_info(self.subject_name, version, has_public_executable=True) is not None + + @abstractmethod + def has_public_commit_executable(self, commit_nb: int) -> bool: + pass + @abstractmethod - def has_public_executable(self, state_index: int, state_type: Literal['release', 'commit']) -> bool: + def get_release_executable_urls(self, version: Version) -> list[str]: pass @abstractmethod - def get_executable_download_urls(self, state_index: int, state_type: Literal['release', 'commit']) -> list[str]: + def get_commit_executable_urls(self, commit_nb: int) -> list[str]: pass def get_nearest_state_with_public_executable( @@ -139,17 +165,15 @@ def _parse_commit_nb_from_googlesource(html: str) -> Optional[str]: return None @staticmethod - def _get_earliest_tag_with_major(all_release_tags: list[str], major_release: int) -> str: + def _get_earliest_tag_version_match(all_release_tags: list[str], release_version: Version) -> str: candidates = [] for tag in all_release_tags: v = StateOracle.get_full_version_from_release_tag(tag) - if v is None or not v.startswith(f'{major_release}.'): - continue - parts = tuple(int(p) for p in v.split('.')) - candidates.append((parts, tag)) + if v is not None and release_version.matches(v): + candidates.append((v, tag)) if not candidates: - raise ValueError(f'Could not find earliest tag for major {major_release}.') + raise ValueError(f'Could not find earliest tag for {release_version}.') candidates.sort() return candidates[0][1] diff --git a/bughog/subject/subject.py b/bughog/subject/subject.py index 78ecd2ae..91297719 100644 --- a/bughog/subject/subject.py +++ b/bughog/subject/subject.py @@ -12,7 +12,7 @@ from bughog.evaluation.collectors.collector import Collector from bughog.evaluation.file_structure import Folder -from bughog.parameters import EvaluationParameters, SubjectConfiguration +from bughog.parameters import ExperimentParameters, SubjectConfiguration from bughog.subject.executable import Executable from bughog.subject.simulation import Simulation from bughog.subject.state_oracle import StateOracle @@ -44,14 +44,6 @@ def name(self) -> str: """ pass - @property - @abstractmethod - def _state_oracle_class(self) -> type[StateOracle]: - """ - Returns the state oracle class associated with this subject. - """ - pass - @abstractmethod def create_executable(self, subject_configuration: SubjectConfiguration, state: State) -> Executable: """ @@ -59,16 +51,9 @@ def create_executable(self, subject_configuration: SubjectConfiguration, state: """ pass - @abstractmethod - def get_availability(self) -> dict: - """ - Returns availability data (supported minimum and maximum release version) of this subject. - """ - pass - @staticmethod @abstractmethod - def create_simulation(executable: Executable, context: Folder, params: EvaluationParameters) -> Simulation: + def create_simulation(executable: Executable, context: Folder, params: ExperimentParameters) -> Simulation: """ Creates and returns the simulation object based on the given executable, experiment context and eval params. """ @@ -83,11 +68,12 @@ def create_result_collector() -> Collector: pass @property + @abstractmethod def state_oracle(self) -> StateOracle: """ Creates and returns the state oracle associated with this subject. """ - return self._state_oracle_class(self.type, self.name) + pass @property def assets_folder_path(self) -> str: @@ -95,3 +81,29 @@ def assets_folder_path(self) -> str: Returns the paths of the assets folder associated with this subject. """ return os.path.join('/app/subject', self.type, self.name) + + def get_availability(self) -> dict[str, str | int | list[str]]: + earliest_version = self.state_oracle.get_earliest_supported_release_version() + latest_version = self.state_oracle.get_latest_supported_release_version() + + if earliest_version.major != 0 and latest_version.major != 0: + earliest_major_version = earliest_version.major + latest_major_version = latest_version.major + available_versions = [ + str(version) for version in list(range(earliest_major_version, latest_major_version + 1)) + ] + else: + earliest_major_version = earliest_version.base_version + latest_major_version = latest_version.base_version + available_versions = [str(version) for version in self.state_oracle.get_all_available_release_versions()] + + earliest_commit_number = self.state_oracle.find_commit_of_release(earliest_version)[0] + latest_commit_number = self.state_oracle.find_commit_of_release(latest_version)[0] + return { + 'name': self.name, + 'min_version': earliest_major_version, + 'max_version': latest_major_version, + 'min_commit': earliest_commit_number, + 'max_commit': latest_commit_number, + 'available_versions': available_versions, + } diff --git a/bughog/subject/wasm_runtime/subject.py b/bughog/subject/wasm_runtime/subject.py index 6229fb8a..1147a8b7 100644 --- a/bughog/subject/wasm_runtime/subject.py +++ b/bughog/subject/wasm_runtime/subject.py @@ -1,7 +1,7 @@ from bughog.evaluation.collectors.collector import Collector from bughog.evaluation.collectors.logs import LogCollector from bughog.evaluation.file_structure import Folder -from bughog.parameters import EvaluationParameters +from bughog.parameters import ExperimentParameters from bughog.subject.executable import Executable from bughog.subject.subject import Subject from bughog.subject.wasm_runtime.simulation import WasmRuntimeSimulation @@ -13,7 +13,9 @@ def type(self) -> str: return 'wasm_runtime' @staticmethod - def create_simulation(executable: Executable, context: Folder, params: EvaluationParameters) -> WasmRuntimeSimulation: + def create_simulation( + executable: Executable, context: Folder, params: ExperimentParameters + ) -> WasmRuntimeSimulation: return WasmRuntimeSimulation(executable, context, params) @staticmethod diff --git a/bughog/subject/wasm_runtime/wasmtime/state_oracle.py b/bughog/subject/wasm_runtime/wasmtime/state_oracle.py index 82afc34d..fb3a5092 100644 --- a/bughog/subject/wasm_runtime/wasmtime/state_oracle.py +++ b/bughog/subject/wasm_runtime/wasmtime/state_oracle.py @@ -1,15 +1,16 @@ import re -from typing import Literal from bughog.database.mongo.cache import Cache from bughog.subject.state_oracle import StateOracle from bughog.version_control.conversion import bughog_service, github +from bughog.version_control.version import Version class WasmtimeStateOracle(StateOracle): """ State oracle for Wasmtime. """ + def __init__(self, subject_type: str, subject_name: str) -> None: super().__init__(subject_type, subject_name, only_artisanal=True) @@ -22,19 +23,21 @@ def find_commit_id(self, commit_nb: int) -> str | None: return bughog_service.find_commit_id('wasmtime', commit_nb) @Cache.cache_in_db('wasm_runtime', 'wasmtime') - def find_commit_of_release(self, release_version: int) -> tuple[int, str]: + def find_commit_of_release(self, release_version: Version) -> tuple[int, str]: # TODO: make more efficient, possibly by adding functionality to bughog service all_release_tags = self.__get_all_release_tags() - major_release_tag = self._get_earliest_tag_with_major(all_release_tags, release_version) + major_release_tag = self._get_earliest_tag_version_match(all_release_tags, release_version) commit_id = github.find_commit_id_from_tag('bytecodealliance', 'wasmtime', major_release_tag) commit_nb = self.find_commit_nb(commit_id) return commit_nb, commit_id - def get_most_recent_major_release_version(self) -> int: + def get_earliest_supported_release_version(self) -> Version: + return Version('1') + + def get_latest_supported_release_version(self) -> Version: all_release_tags = self.__get_all_release_tags() - truncated_tags = [self.get_full_version_from_release_tag(tag) for tag in all_release_tags] - major_versions = set(int(tag.split('.')[0]) for tag in truncated_tags if tag is not None) - return max(major_versions) + versions = [self.get_full_version_from_release_tag(tag) for tag in all_release_tags] + return max(versions) @staticmethod @Cache.cache_in_db('wasm_runtime', 'wasmtime', ttl=24) @@ -52,11 +55,19 @@ def get_commit_url(self, commit_nb: int, commit_id: str | None) -> str | None: return None return f'https://github.com/bytecodealliance/wasmtime/commit/{commit_id}' - def has_public_executable(self, state_index: int, state_type: Literal['release', 'commit']) -> bool: + def has_public_release_executable(self, version: Version) -> bool: + return False + + def has_public_commit_executable(self, commit_nb: int) -> bool: return False - def get_executable_download_urls(self, state_index: int, state_type: Literal['release', 'commit']) -> list[str]: + def get_release_executable_urls(self, version: Version) -> list[str]: + return [] + + def get_commit_executable_urls(self, commit_nb: int) -> list[str]: return [] - def get_nearest_commit_with_executable(self, target_commit_nb: int, lower_bound: int, upper_bound: int) -> int | None: + def get_nearest_commit_with_executable( + self, target_commit_nb: int, lower_bound: int, upper_bound: int + ) -> int | None: NotImplementedError() diff --git a/bughog/subject/wasm_runtime/wasmtime/subject.py b/bughog/subject/wasm_runtime/wasmtime/subject.py index 252b6808..1f319662 100644 --- a/bughog/subject/wasm_runtime/wasmtime/subject.py +++ b/bughog/subject/wasm_runtime/wasmtime/subject.py @@ -11,15 +11,8 @@ def name(self) -> str: return 'wasmtime' @property - def _state_oracle_class(self) -> type[WasmtimeStateOracle]: - return WasmtimeStateOracle - - def get_availability(self) -> dict: - return { - 'name': 'wasmtime', - 'min_version': 1, - 'max_version': self.state_oracle.get_most_recent_major_release_version() - } + def state_oracle(self) -> WasmtimeStateOracle: + return WasmtimeStateOracle(self.type, self.name) def create_executable(self, subject_configuration: SubjectConfiguration, state: State) -> WasmtimeExecutable: return WasmtimeExecutable(subject_configuration, state) diff --git a/bughog/subject/web_browser/chromium/executable.py b/bughog/subject/web_browser/chromium/executable.py index 539d857e..8bf515d8 100644 --- a/bughog/subject/web_browser/chromium/executable.py +++ b/bughog/subject/web_browser/chromium/executable.py @@ -1,31 +1,44 @@ import os import re -from bughog import cli, util +from bughog import cli from bughog.parameters import SubjectConfiguration from bughog.subject.web_browser.executable import BrowserExecutable from bughog.subject.web_browser.profile import prepare_chromium_profile, remove_profile_execution_folder +from bughog.util import fs from bughog.version_control.state.base import State DEFAULT_FLAGS = [ + # Automation / testing setup + '--no-sandbox', + '--no-first-run', + '--no-default-browser-check', '--use-fake-ui-for-media-stream', '--ignore-certificate-errors', - '--disable-background-networking', - '--disable-client-side-phishing-detection', - '--disable-component-update', - '--disable-default-apps', + '--use-mock-keychain', + '--password-store=basic', + '--metrics-recording-only', + '--mute-audio', '--disable-gpu', - '--disable-hang-monitor', '--disable-popup-blocking', '--disable-prompt-on-repost', + # Reduce background network activity + '--disable-background-networking', '--disable-sync', '--disable-web-resources', - '--metrics-recording-only', - '--no-first-run', - '--password-store=basic', + '--disable-component-update', + '--disable-client-side-phishing-detection', '--safebrowsing-disable-auto-update', - '--use-mock-keychain', - '--no-sandbox', + '--disable-features=OptimizationGuide,OptimizationHints,OptimizationTargetPrediction,OptimizationGuideModelDownloading', + # Faster startup / skip unnecessary services + '--disable-default-apps', + '--disable-translate', + '--disable-breakpad', + '--disable-hang-monitor', + # Prevent throttling in automated/non-foreground context + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + '--disable-ipc-flooding-protection', ] @@ -50,7 +63,7 @@ def _optimize_for_storage(self): # Remove unneccessary files locales_folder_path = os.path.join(self.staging_folder, 'locales') if os.path.isdir(locales_folder_path): - util.remove_all_in_folder(locales_folder_path, except_files=['en-GB.pak', 'en-US.pak']) + fs.remove_all_in_folder(locales_folder_path, except_files=['en-GB.pak', 'en-US.pak']) def _configure_executable(self): cli.execute_and_return_status(f'chmod -R a+x {self.staging_folder}') @@ -96,19 +109,21 @@ def _prepare_profile_folder(self): case 'default': profile_path = prepare_chromium_profile() case 'btpc': - if int(self.version) < 17: + assert self.version is not None + major = self.version.major + if major < 17: profile_path = prepare_chromium_profile('6_btpc') - elif int(self.version) < 24: + elif major < 24: profile_path = prepare_chromium_profile('17_btpc') - elif int(self.version) < 36: + elif major < 36: profile_path = prepare_chromium_profile('24_btpc') - elif int(self.version) < 40: + elif major < 40: profile_path = prepare_chromium_profile('36_btpc') - elif int(self.version) < 46: + elif major < 46: profile_path = prepare_chromium_profile('40_btpc') - elif int(self.version) < 59: + elif major < 59: profile_path = prepare_chromium_profile('46_btpc') - elif int(self.version) < 86: + elif major < 86: profile_path = prepare_chromium_profile('59_btpc') else: raise AttributeError('Chrome 86 and up not supported yet') diff --git a/bughog/subject/web_browser/chromium/state_oracle.py b/bughog/subject/web_browser/chromium/state_oracle.py index 311d42c7..b13413d1 100644 --- a/bughog/subject/web_browser/chromium/state_oracle.py +++ b/bughog/subject/web_browser/chromium/state_oracle.py @@ -1,13 +1,13 @@ import logging import re -from typing import Literal import requests -from bughog import util from bughog.database.mongo.cache import Cache from bughog.subject.state_oracle import StateOracle +from bughog.util import http from bughog.version_control.conversion import bughog_service +from bughog.version_control.version import Version logger = logging.getLogger(__name__) @@ -26,7 +26,7 @@ def find_commit_nb(self, commit_id: str) -> int: # If not found, use googlesource. url = f'{REV_ID_BASE_URL}{commit_id}' - html = util.request_html(url).decode() + html = http.request_html(url).decode() commit_nb = self._parse_commit_nb_from_googlesource(html) if commit_nb is None: logger.error(f"Could not parse commit number on '{url}'") @@ -42,48 +42,39 @@ def find_commit_id(self, commit_nb: int) -> str | None: # If not found, use crrev.com. try: - final_url = util.request_final_url(f'{REV_NUMBER_BASE_URL}{commit_nb}') - except util.ResourceNotFound: + final_url = http.request_final_url(f'{REV_NUMBER_BASE_URL}{commit_nb}') + except http.ResourceNotFound: return None commit_id = final_url[-40:] assert re.match(r'[a-z0-9]{40}', commit_id) return commit_id - # @Cache.cache_in_db('web_browser', 'chromium') - def find_commit_of_release(self, release_version: int) -> tuple[int, str]: - return bughog_service.find_version_commit('chromium', release_version, has_public_executable=True) + def get_earliest_supported_release_version(self) -> Version: + return Version('20') - def get_most_recent_major_release_version(self) -> int: - return bughog_service.find_latest_major_version('chromium') + # @Cache.cache_in_db('web_browser', 'chromium') + def has_public_commit_executable(self, commit_nb: int) -> bool: + url = f'https://www.googleapis.com/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F{commit_nb}%2Fchrome-linux.zip' + req = requests.get(url) + # TODO: caching at factory + return req.status_code == 200 # @Cache.cache_in_db('web_browser', 'chromium') - def has_public_executable(self, state_index: int, state_type: Literal['release', 'commit']) -> bool: - match state_type: - case 'release': - # TODO: make more efficient (by possibly adding to bughog service) - commit_nb, _ = bughog_service.find_version_commit('chromium', state_index, has_public_executable=True) - executable_info = bughog_service.find_commit_executable_info('chromium', commit_nb) - if executable_info is None: - return self.has_public_executable(commit_nb, 'commit') - return True - case 'commit': - url = f'https://www.googleapis.com/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F{state_index}%2Fchrome-linux.zip' - req = requests.get(url) - has_binary_online = req.status_code == 200 - # TODO: caching at factory - return has_binary_online + def get_release_executable_urls(self, version: Version) -> list[str]: + # TODO: make more efficient (by possibly adding to bughog service) + version_info = bughog_service.find_version_info('chromium', version, has_public_executable=True) + commit_nb = version_info['commit_info']['nb'] + return self.get_commit_executable_urls(commit_nb) # @Cache.cache_in_db('web_browser', 'chromium') - def get_executable_download_urls(self, state_index: int, state_type: Literal['release', 'commit']) -> list[str]: - match state_type: - case 'release': - # TODO: make more efficient (by possibly adding to bughog service) - commit_nb, _ = bughog_service.find_version_commit('chromium', state_index, has_public_executable=True) - return self.get_executable_download_urls(commit_nb, 'commit') - case 'commit': - return [f'https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F{state_index}%2Fchrome-linux.zip?alt=media'] - - def get_nearest_commit_with_executable(self, target_commit_nb: int, lower_bound: int, upper_bound: int) -> int | None: + def get_commit_executable_urls(self, commit_nb: int) -> list[str]: + return [ + f'https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F{commit_nb}%2Fchrome-linux.zip?alt=media' + ] + + def get_nearest_commit_with_executable( + self, target_commit_nb: int, lower_bound: int, upper_bound: int + ) -> int | None: NotImplementedError() # Commit state functions diff --git a/bughog/subject/web_browser/chromium/subject.py b/bughog/subject/web_browser/chromium/subject.py index cc4d5002..c833b8c8 100644 --- a/bughog/subject/web_browser/chromium/subject.py +++ b/bughog/subject/web_browser/chromium/subject.py @@ -1,15 +1,9 @@ -import logging - from bughog.parameters import SubjectConfiguration -from bughog.subject.state_oracle import StateOracle from bughog.subject.web_browser.chromium.executable import ChromiumExecutable from bughog.subject.web_browser.chromium.state_oracle import ChromiumStateOracle from bughog.subject.web_browser.subject import WebBrowser -from bughog.version_control.conversion import bughog_service from bughog.version_control.state.base import State -logger = logging.getLogger(__name__) - class Chromium(WebBrowser): @property @@ -17,12 +11,8 @@ def name(self) -> str: return 'chromium' @property - def _state_oracle_class(self) -> type[StateOracle]: - return ChromiumStateOracle + def state_oracle(self) -> ChromiumStateOracle: + return ChromiumStateOracle(self.type, self.name) def create_executable(self, subject_configuration: SubjectConfiguration, state: State) -> ChromiumExecutable: return ChromiumExecutable(subject_configuration, state) - - def get_availability(self) -> dict: - most_recent_major_version = bughog_service.find_latest_major_version('chromium') - return {'name': 'chromium', 'min_version': 20, 'max_version': most_recent_major_version} diff --git a/bughog/subject/web_browser/executable.py b/bughog/subject/web_browser/executable.py index 33d4f053..d42be7d7 100644 --- a/bughog/subject/web_browser/executable.py +++ b/bughog/subject/web_browser/executable.py @@ -1,8 +1,8 @@ from abc import abstractmethod -from bughog import util from bughog.parameters import SubjectConfiguration from bughog.subject.executable import Executable +from bughog.util import fs from bughog.version_control.state.base import State @@ -33,7 +33,7 @@ def _remove_profile_folder(self): def __empty_downloads_folder(self): download_folder = '/root/Downloads' - util.remove_all_in_folder(download_folder) + fs.remove_all_in_folder(download_folder) def pre_experiment_setup(self): self.fetch() diff --git a/bughog/subject/web_browser/firefox/executable.py b/bughog/subject/web_browser/firefox/executable.py index 09239f3d..f9546189 100644 --- a/bughog/subject/web_browser/firefox/executable.py +++ b/bughog/subject/web_browser/firefox/executable.py @@ -9,6 +9,31 @@ SELENIUM_USED_FLAGS = ['--no-remote', '--new-instance'] +DEFAULT_PREFS = { + # Automation / testing setup + 'app.update.enabled': False, + 'browser.shell.checkDefaultBrowser': False, + 'dom.push.enabled': False, + 'browser.translation.detectLanguage': False, + 'media.volume_scale': '0.0', + # Disable telemetry & crash reporting + 'toolkit.telemetry.enabled': False, + 'toolkit.telemetry.unified': False, + 'datareporting.healthreport.uploadEnabled': False, + 'datareporting.policy.dataSubmissionEnabled': False, + 'breakpad.reportURL': '', + 'browser.tabs.crashReporting.sendReport': False, + # Reduce background network activity + 'browser.safebrowsing.malware.enabled': False, + 'browser.safebrowsing.phishing.enabled': False, + 'browser.safebrowsing.downloads.enabled': False, + 'browser.safebrowsing.blockedURIs.enabled': False, + 'browser.newtabpage.activity-stream.feeds.telemetry': False, + 'browser.newtabpage.activity-stream.telemetry': False, + 'browser.newtabpage.activity-stream.feeds.snippets': False, + 'browser.newtabpage.activity-stream.feeds.section.topstories': False, +} + class FirefoxExecutable(BrowserExecutable): def __init__(self, config: SubjectConfiguration, state: State) -> None: @@ -68,19 +93,23 @@ def add_user_pref(key: str, value: str | int | bool): else: user_prefs.append(f'user_pref("{key}", {value});'.lower()) - add_user_pref('app.update.enabled', False) - add_user_pref('browser.shell.checkDefaultBrowser', False) + for key, value in DEFAULT_PREFS.items(): + add_user_pref(key, value) + if 'default' in self.config.subject_setting: pass elif 'btpc' in self.config.subject_setting: add_user_pref('network.cookie.cookieBehavior', 1) add_user_pref('browser.contentblocking.category', 'custom') elif 'tp' in self.config.subject_setting: - if int(self.version) >= 65: + assert self.version is not None + if self.version.major >= 65: add_user_pref('privacy.trackingprotection.enabled', True) add_user_pref('pref.privacy.disable_button.change_blocklis', False) add_user_pref('pref.privacy.disable_button.tracking_protection_exceptions', False) - add_user_pref('urlclassifier.trackingTable', 'test-track-simple,base-track-digest256,content-track-digest256') + add_user_pref( + 'urlclassifier.trackingTable', 'test-track-simple,base-track-digest256,content-track-digest256' + ) else: add_user_pref('privacy.contentblocking.category', 'strict') add_user_pref('privacy.trackingprotection.enabled', True) @@ -124,10 +153,14 @@ def _prepare_profile_folder(self): # For newer Firefox versions (> 57): # Generate SQLite database: cert9.db key4.db pkcs11.txt - cli.execute(f'certutil -A -n bughog-ca -t CT,c -i /etc/nginx/ssl/certs/bughog_CA.crt -d sql:{self._profile_path}') + cli.execute( + f'certutil -A -n bughog-ca -t CT,c -i /etc/nginx/ssl/certs/bughog_CA.crt -d sql:{self._profile_path}' + ) # For older Firefox versions (<= 57): # Generate in Berkeley DB database: cert8.db, key3.db, secmod.db - cli.execute(f'certutil -A -n bughog-ca -t CT,c -i /etc/nginx/ssl/certs/bughog_CA.crt -d dbm:{self._profile_path}') + cli.execute( + f'certutil -A -n bughog-ca -t CT,c -i /etc/nginx/ssl/certs/bughog_CA.crt -d dbm:{self._profile_path}' + ) # More info: # - https://support.mozilla.org/en-US/questions/1207165 diff --git a/bughog/subject/web_browser/firefox/state_oracle.py b/bughog/subject/web_browser/firefox/state_oracle.py index b049f88d..5c67b493 100644 --- a/bughog/subject/web_browser/firefox/state_oracle.py +++ b/bughog/subject/web_browser/firefox/state_oracle.py @@ -1,8 +1,7 @@ -from typing import Literal - from bughog.database.mongo.cache import Cache from bughog.subject.state_oracle import StateOracle from bughog.version_control.conversion import bughog_service +from bughog.version_control.version import Version class FirefoxStateOracle(StateOracle): @@ -14,40 +13,37 @@ def find_commit_nb(self, commit_id: str) -> int: def find_commit_id(self, commit_nb: int) -> str | None: return bughog_service.find_commit_id('firefox', commit_nb) - # @Cache.cache_in_db('web_browser', 'firefox') - def find_commit_of_release(self, release_version: int) -> tuple[int, str]: - return bughog_service.find_version_commit('firefox', release_version) + def get_earliest_supported_release_version(self) -> Version: + return Version('20.0') - def get_most_recent_major_release_version(self) -> int: - return bughog_service.find_latest_major_version('firefox') + @Cache.cache_in_db('web_browser', 'firefox') + def has_public_release_executable(self, version: Version) -> bool: + return True @Cache.cache_in_db('web_browser', 'firefox') - def has_public_executable(self, state_index: int, state_type: Literal['release', 'commit']) -> bool: - match state_type: - case 'release': - return True - case 'commit': - return bughog_service.find_commit_executable_info('firefox', state_index) is not None - - def get_executable_download_urls(self, state_index: int, state_type: Literal['release', 'commit']) -> list[str]: - match state_type: - case 'release': - return [ - f'https://ftp.mozilla.org/pub/firefox/releases/{state_index}.0/linux-x86_64/en-US/firefox-{state_index}.0.tar.bz2', - f'https://ftp.mozilla.org/pub/firefox/releases/{state_index}.0/linux-x86_64/en-US/firefox-{state_index}.0.tar.xz', - ] - case 'commit': - info = bughog_service.find_commit_executable_info('firefox', state_index) - if info is None: - raise AttributeError(f"Could not find binary url for '{state_index}'") - binary_base_url = info['base_url'] - app_version = info['app_version'] - return [ - f'{binary_base_url}firefox-{app_version}.en-US.linux-x86_64.tar.bz2', - f'{binary_base_url}firefox-{app_version}.en-US.linux-x86_64.tar.xz', - ] - - def get_nearest_commit_with_executable(self, target_commit_nb: int, lower_bound: int, upper_bound: int) -> int | None: + def has_public_commit_executable(self, commit_nb: int) -> bool: + return bughog_service.find_commit_executable_info('firefox', commit_nb) is not None + + def get_release_executable_urls(self, version: Version) -> list[str]: + return [ + f'https://ftp.mozilla.org/pub/firefox/releases/{version}.0/linux-x86_64/en-US/firefox-{version}.0.tar.bz2', + f'https://ftp.mozilla.org/pub/firefox/releases/{version}.0/linux-x86_64/en-US/firefox-{version}.0.tar.xz', + ] + + def get_commit_executable_urls(self, commit_nb: int) -> list[str]: + info = bughog_service.find_commit_executable_info('firefox', commit_nb) + if info is None: + raise AttributeError(f"Could not find binary url for '{commit_nb}'") + binary_base_url = info['base_url'] + app_version = info['app_version'] + return [ + f'{binary_base_url}firefox-{app_version}.en-US.linux-x86_64.tar.bz2', + f'{binary_base_url}firefox-{app_version}.en-US.linux-x86_64.tar.xz', + ] + + def get_nearest_commit_with_executable( + self, target_commit_nb: int, lower_bound: int, upper_bound: int + ) -> int | None: NotImplementedError() def get_commit_url(self, commit_nb: int, commit_id: str | None) -> str | None: diff --git a/bughog/subject/web_browser/firefox/subject.py b/bughog/subject/web_browser/firefox/subject.py index 33d8fca7..16e71322 100644 --- a/bughog/subject/web_browser/firefox/subject.py +++ b/bughog/subject/web_browser/firefox/subject.py @@ -1,8 +1,6 @@ from bughog.parameters import SubjectConfiguration -from bughog.subject.state_oracle import StateOracle from bughog.subject.web_browser.firefox.executable import FirefoxExecutable from bughog.subject.web_browser.firefox.state_oracle import FirefoxStateOracle -from bughog.version_control.conversion import bughog_service from bughog.subject.web_browser.subject import WebBrowser from bughog.version_control.state.base import State @@ -13,16 +11,8 @@ def name(self) -> str: return 'firefox' @property - def _state_oracle_class(self) -> type[StateOracle]: - return FirefoxStateOracle + def state_oracle(self) -> FirefoxStateOracle: + return FirefoxStateOracle(self.type, self.name) def create_executable(self, subject_configuration: SubjectConfiguration, state: State) -> FirefoxExecutable: return FirefoxExecutable(subject_configuration, state) - - def get_availability(self) -> dict: - max_version = bughog_service.find_latest_major_version('firefox') - return { - 'name': 'firefox', - 'min_version': 20, - 'max_version': max_version, - } diff --git a/bughog/subject/web_browser/interaction/simulation.py b/bughog/subject/web_browser/interaction/simulation.py index 4f611e93..ab0d804f 100644 --- a/bughog/subject/web_browser/interaction/simulation.py +++ b/bughog/subject/web_browser/interaction/simulation.py @@ -1,3 +1,4 @@ +import logging import os from urllib.parse import quote_plus @@ -5,19 +6,28 @@ from pyvirtualdisplay.display import Display from bughog.evaluation.file_structure import Folder -from bughog.parameters import EvaluationParameters +from bughog.parameters import ExperimentParameters from bughog.subject.simulation import Simulation from bughog.subject.web_browser.executable import BrowserExecutable +logger = logging.getLogger(__name__) + # TODO: all pyautogui are imported inside functions because the import needs DISPLAY var, while not all containers need and have that. + class BrowserSimulation(Simulation): - def __init__(self, executable: BrowserExecutable, folder: Folder, params: EvaluationParameters): - import pyautogui as gui + executable: BrowserExecutable + + def __init__(self, executable: BrowserExecutable, folder: Folder, params: ExperimentParameters): super().__init__(executable, folder, params) disp = Display(visible=True, size=(1920, 1080), backend='xvfb', use_xauth=True) disp.start() - gui._pyautogui_x11._display = Xlib.display.Display(os.environ['DISPLAY']) + + display = os.environ['DISPLAY'] + logger.info(f'BrowserSimulation initialized with DISPLAY={display}') + + import pyautogui as gui + gui._pyautogui_x11._display = Xlib.display.Display(display) # ty: ignore (import will create error) def __del__(self): self.executable.terminate() @@ -72,6 +82,7 @@ def new_tab(self, url: str): def click_position(self, x: str, y: str): import pyautogui as gui + max_x, max_y = gui.size() gui.moveTo(self.parse_position(x, max_x), self.parse_position(y, max_y)) @@ -79,36 +90,46 @@ def click_position(self, x: str, y: str): def click(self, el_id: str): import pyautogui as gui + el_image_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), f'elements/{el_id}.png') x, y = gui.locateCenterOnScreen(el_image_path) self.click_position(str(x), str(y)) def write(self, text: str): import pyautogui as gui + gui.write(text, interval=0.1) def press(self, key: str): import pyautogui as gui + gui.press(key) def hold(self, key: str): import pyautogui as gui + gui.keyDown(key) def release(self, key: str): import pyautogui as gui + gui.keyUp(key) def hotkey(self, *keys: str): import pyautogui as gui + gui.hotkey(*keys) def screenshot(self, filename: str): import pyautogui as gui - project_name = self.params.evaluation_range.project_name - experiment_name = self.params.evaluation_range.experiment_name + + project_name = self.params.project_name + experiment_name = self.params.experiment_name executable_name = f'{self.executable.executable_name}-{self.executable.version}' - file_path = os.path.join('/app/logs/screenshots/', f'{project_name}-{experiment_name}-{self.executable.state.name}-{executable_name}.jpg') + file_path = os.path.join( + '/app/logs/screenshots/', + f'{project_name}-{experiment_name}-{self.executable.state.name}-{executable_name}.jpg', + ) gui.screenshot(file_path) def reproduced(self): diff --git a/subject/web_browser/executable/Dockerfile b/bughog/subject/web_browser/servo/__init__.py similarity index 100% rename from subject/web_browser/executable/Dockerfile rename to bughog/subject/web_browser/servo/__init__.py diff --git a/bughog/subject/web_browser/servo/executable.py b/bughog/subject/web_browser/servo/executable.py new file mode 100644 index 00000000..32792247 --- /dev/null +++ b/bughog/subject/web_browser/servo/executable.py @@ -0,0 +1,59 @@ +import os +import re + +from bughog import cli +from bughog.parameters import SubjectConfiguration +from bughog.subject.web_browser.executable import BrowserExecutable +from bughog.version_control.state.base import State + + +class ServoExecutable(BrowserExecutable): + def __init__(self, config: SubjectConfiguration, state: State) -> None: + super().__init__(config, state) + self._profile_path = None + + @property + def executable_name(self) -> str: + if os.path.isfile(os.path.join(self.temporary_storage_folder, 'servo')): + return 'servo' + if os.path.isfile(os.path.join(self.temporary_storage_folder, 'servoshell')): + return 'servoshell' + raise FileNotFoundError(f"No executable found for state '{self.state.name}' in the storage folder.") + + def _get_version(self) -> str: + command = f'./{self.executable_name} --version' + output = cli.execute_and_return_output(command, cwd=self.staging_folder) + match = re.search(r'Servo (?P[0-9]+\.[0-9]+\.[0-9]+(-\w+)?)', output) + if match: + return match.group('version') + raise AttributeError(f"Could not determine version of executable at '{self.executable_name}'.") + + def _optimize_for_storage(self) -> None: + pass + + def _configure_executable(self) -> None: + cli.execute_and_return_status(f'chmod -R a+x {self.staging_folder}') + + @property + def post_experiment_sleep_duration(self) -> int: + return 1 + + @property + def open_console_hotkey(self) -> list[str]: + """ + This is not implemented, but we simply ignore the command in the interaction script. + """ + return [] + + @property + def supported_options(self) -> list[str]: + return [] + + def _get_cli_command(self) -> list[str]: + return [self.executable_path, '--ignore-certificate-errors'] + + def _prepare_profile_folder(self): + pass + + def _remove_profile_folder(self): + pass diff --git a/bughog/subject/web_browser/servo/state_oracle.py b/bughog/subject/web_browser/servo/state_oracle.py new file mode 100644 index 00000000..f3dda9a5 --- /dev/null +++ b/bughog/subject/web_browser/servo/state_oracle.py @@ -0,0 +1,44 @@ +from bughog.subject.state_oracle import StateOracle +from bughog.version_control.conversion import bughog_service +from bughog.version_control.version import Version + + +class ServoStateOracle(StateOracle): + def find_commit_nb(self, commit_id: str) -> int: + return bughog_service.find_commit_nb(self.subject_name, commit_id) + + def find_commit_id(self, commit_nb: int) -> str | None: + return bughog_service.find_commit_id(self.subject_name, commit_nb) + + def get_earliest_supported_release_version(self) -> Version: + return Version('0.0.1') + + def has_public_commit_executable(self, commit_nb: int) -> bool: + return bughog_service.find_commit_executable_info(self.subject_name, commit_nb) is not None + + def get_release_executable_urls(self, version: Version) -> list[str]: + version_info = bughog_service.find_version_info(self.subject_name, version, has_public_executable=True) + if version_info is None: + return [] + base_url = version_info.get('executable_info', {}).get('base_url') + assets = version_info.get('executable_info', {}).get('assets', []) + if base_url is None or 'servo-x86_64-linux-gnu.tar.gz' not in assets: + return [] + return [base_url + 'servo-x86_64-linux-gnu.tar.gz'] + + def get_commit_executable_urls(self, commit_nb: int) -> list[str]: + commit_info = bughog_service.find_commit_executable_info(self.subject_name, commit_nb) + if commit_info is None: + return [] + return [commit_info['base_url'] + 'servo-latest.tar.gz'] + + def get_nearest_commit_with_executable( + self, target_commit_nb: int, lower_bound: int, upper_bound: int + ) -> int | None: + commit_info = bughog_service.find_nearest_commit_with_executable( + self.subject_name, target_commit_nb, lower_bound, upper_bound + ) + return commit_info.get('nb') if commit_info else None + + def get_commit_url(self, commit_nb: int, commit_id: str | None) -> str | None: + return f'https://github.com/servo/servo/commit/{commit_id}' diff --git a/bughog/subject/web_browser/servo/subject.py b/bughog/subject/web_browser/servo/subject.py new file mode 100644 index 00000000..6698cb75 --- /dev/null +++ b/bughog/subject/web_browser/servo/subject.py @@ -0,0 +1,18 @@ +from bughog.parameters import SubjectConfiguration +from bughog.subject.web_browser.servo.executable import ServoExecutable +from bughog.subject.web_browser.servo.state_oracle import ServoStateOracle +from bughog.subject.web_browser.subject import WebBrowser +from bughog.version_control.state.base import State + + +class Servo(WebBrowser): + @property + def name(self) -> str: + return 'servo' + + @property + def state_oracle(self) -> ServoStateOracle: + return ServoStateOracle(self.type, self.name) + + def create_executable(self, subject_configuration: SubjectConfiguration, state: State) -> ServoExecutable: + return ServoExecutable(subject_configuration, state) diff --git a/bughog/subject/web_browser/state_cache.py b/bughog/subject/web_browser/state_cache.py index 7115ca25..83742580 100644 --- a/bughog/subject/web_browser/state_cache.py +++ b/bughog/subject/web_browser/state_cache.py @@ -4,131 +4,135 @@ from pymongo import ASCENDING, DESCENDING -from bughog import util from bughog.database.mongo.mongodb import MongoDB +from bughog.util import http logger = logging.getLogger(__name__) -BASE_URL = "https://bughog.distrinet-research.be/" +BASE_URL = 'https://bughog.distrinet-research.be/' class PublicBrowserStateCache: @staticmethod def update() -> None: def safe_request_json_and_update(collection_name: str, transform=lambda x: x): - url = BASE_URL + collection_name + ".json" + url = BASE_URL + collection_name + '.json' try: - result = util.request_json(url)["data"] + result = http.fetch_dict(url).get('data') if result is not None: PublicBrowserStateCache.__update_collection(collection_name, transform(result)) - except util.ResourceNotFound: - logger.warning(f"Could not update commit cache with resource at {url}") + except http.ResourceNotFound: + logger.warning(f'Could not update commit cache with resource at {url}') except Exception: - logger.error(f"Could not update commit cache for {collection_name}", exc_info=True) + logger.error(f'Could not update commit cache for {collection_name}', exc_info=True) executor = ThreadPoolExecutor() - executor.submit(safe_request_json_and_update, "firefox_binary_availability", transform=lambda x: list(x.values())) - executor.submit(safe_request_json_and_update, "firefox_release_base_revs") - executor.submit(safe_request_json_and_update, "chromium_release_base_revs") + executor.submit( + safe_request_json_and_update, 'firefox_binary_availability', transform=lambda x: list(x.values()) + ) + executor.submit(safe_request_json_and_update, 'firefox_release_base_revs') + executor.submit(safe_request_json_and_update, 'chromium_release_base_revs') executor.shutdown(wait=False) @staticmethod def __update_collection(collection_name: str, data: list) -> None: collection = MongoDB().get_collection(collection_name) if (n := len(data)) == collection.count_documents({}): - logger.debug(f"{collection_name} is still up-to-date ({n} documents).") + logger.debug(f'{collection_name} is still up-to-date ({n} documents).') else: collection.delete_many({}) collection.insert_many(data) - logger.info(f"{collection_name} is updated ({len(data)} documents).") + logger.info(f'{collection_name} is updated ({len(data)} documents).') @staticmethod def firefox_get_commit_nb(commit_id: str) -> int: - collection = MongoDB().get_collection("firefox_binary_availability") - result = collection.find_one({"revision_id": commit_id}, {"revision_number": 1}) - if result is None or "revision_number" not in result: + collection = MongoDB().get_collection('firefox_binary_availability') + result = collection.find_one({'revision_id': commit_id}, {'revision_number': 1}) + if result is None or 'revision_number' not in result: raise AttributeError(f"Could not find 'revision_number' in {result}") - return result["revision_number"] + return result['revision_number'] @staticmethod def firefox_has_executable_for(commit_nb: Optional[int] = None, commit_id: Optional[str] = None) -> bool: - collection = MongoDB().get_collection("firefox_binary_availability") + collection = MongoDB().get_collection('firefox_binary_availability') if commit_nb: - result = collection.find_one({"revision_number": commit_nb}) + result = collection.find_one({'revision_number': commit_nb}) elif commit_id: - result = collection.find_one({"revision_number": commit_nb}) + result = collection.find_one({'revision_number': commit_nb}) else: - raise AttributeError("No commit number or id was provided") + raise AttributeError('No commit number or id was provided') return result is not None @staticmethod def firefox_get_executable_info(commit_id: str) -> Optional[dict]: - collection = MongoDB().get_collection("firefox_binary_availability") - return collection.find_one({"node": commit_id}, {"files_url": 1, "app_version": 1}) + collection = MongoDB().get_collection('firefox_binary_availability') + return collection.find_one({'node': commit_id}, {'files_url': 1, 'app_version': 1}) @staticmethod def firefox_get_previous_and_next_commit_nb_with_executable(commit_nb: int) -> tuple[Optional[int], Optional[int]]: - collection = MongoDB().get_collection("firefox_binary_availability") + collection = MongoDB().get_collection('firefox_binary_availability') - previous_commit_nbs = collection.find({"revision_number": {"$lt": commit_nb}}).sort({"revision_number": DESCENDING}) + previous_commit_nbs = collection.find({'revision_number': {'$lt': commit_nb}}).sort( + {'revision_number': DESCENDING} + ) previous_document = next(previous_commit_nbs, None) - next_commit_nbs = collection.find({"revision_number": {"$gt": commit_nb}}).sort({"revision_number": ASCENDING}) + next_commit_nbs = collection.find({'revision_number': {'$gt': commit_nb}}).sort({'revision_number': ASCENDING}) next_document = next(next_commit_nbs, None) return ( - previous_document["revision_number"] if previous_document else None, - next_document["revision_number"] if next_document else None, + previous_document['revision_number'] if previous_document else None, + next_document['revision_number'] if next_document else None, ) @staticmethod def firefox_get_commit_id(commit_nb: int) -> Optional[str]: - collection = MongoDB().get_collection("firefox_binary_availability") - result = collection.find_one({"revision_number": commit_nb}) + collection = MongoDB().get_collection('firefox_binary_availability') + result = collection.find_one({'revision_number': commit_nb}) if result is None: return None - return result.get("node", None) + return result.get('node', None) @staticmethod def __get_release_base_rev_collection(browser: str) -> str: match browser: - case "chromium": - return "chromium_release_base_revs" - case "firefox": - return "firefox_release_base_revs" + case 'chromium': + return 'chromium_release_base_revs' + case 'firefox': + return 'firefox_release_base_revs' case _: - raise AttributeError(f"Could not get collection for browser {browser}") + raise AttributeError(f'Could not get collection for browser {browser}') @staticmethod def is_tag(browser: str, tag: str) -> bool: collection = MongoDB().get_collection(PublicBrowserStateCache.__get_release_base_rev_collection(browser)) - n = collection.count_documents({"release_tag": tag}) + n = collection.count_documents({'release_tag': tag}) return n > 0 @staticmethod def get_release_tag(browser: str, major_release_version: int) -> str: collection = MongoDB().get_collection(PublicBrowserStateCache.__get_release_base_rev_collection(browser)) - if doc := collection.find_one({"major_version": major_release_version}): - return doc["release_tag"] + if doc := collection.find_one({'major_version': major_release_version}): + return doc['release_tag'] raise AttributeError(f"Could not find release tag associated with version '{major_release_version}'") @staticmethod def get_release_commit_nb(browser: str, major_release_version: int) -> int: collection = MongoDB().get_collection(PublicBrowserStateCache.__get_release_base_rev_collection(browser)) - if doc := collection.find_one({"major_version": major_release_version}): - return doc["revision_number"] + if doc := collection.find_one({'major_version': major_release_version}): + return doc['revision_number'] raise AttributeError(f"Could not find major release version '{major_release_version}'") @staticmethod def get_release_commit_id(browser: str, major_release_version: int) -> str: collection = MongoDB().get_collection(PublicBrowserStateCache.__get_release_base_rev_collection(browser)) - if doc := collection.find_one({"major_version": major_release_version}): - return doc["revision_id"] + if doc := collection.find_one({'major_version': major_release_version}): + return doc['revision_id'] raise AttributeError(f"Could not find major release version '{major_release_version}'") @staticmethod def get_most_recent_major_version(browser: str) -> int: collection = MongoDB().get_collection(PublicBrowserStateCache.__get_release_base_rev_collection(browser)) - if doc := collection.find_one(sort=[("major_version", -1)]): - return doc["major_version"] - raise AttributeError("Could not find most recent major release version") + if doc := collection.find_one(sort=[('major_version', -1)]): + return doc['major_version'] + raise AttributeError('Could not find most recent major release version') diff --git a/bughog/subject/web_browser/subject.py b/bughog/subject/web_browser/subject.py index 42ece35d..aa48aa7f 100644 --- a/bughog/subject/web_browser/subject.py +++ b/bughog/subject/web_browser/subject.py @@ -4,9 +4,10 @@ from bughog.evaluation.collectors.logs import LogCollector from bughog.evaluation.collectors.requests import RequestCollector from bughog.evaluation.file_structure import Folder -from bughog.parameters import EvaluationParameters +from bughog.parameters import ExperimentParameters from bughog.subject.executable import Executable from bughog.subject.subject import Subject +from bughog.subject.web_browser.executable import BrowserExecutable from bughog.subject.web_browser.interaction.simulation import BrowserSimulation @@ -19,7 +20,8 @@ def type(self): return 'web_browser' @staticmethod - def create_simulation(executable: Executable, context: Folder, params: EvaluationParameters) -> BrowserSimulation: + def create_simulation(executable: Executable, context: Folder, params: ExperimentParameters) -> BrowserSimulation: + assert isinstance(executable, BrowserExecutable) return BrowserSimulation(executable, context, params) @staticmethod diff --git a/bughog/util.py b/bughog/util/fs.py similarity index 53% rename from bughog/util.py rename to bughog/util/fs.py index 67e7caff..74579326 100644 --- a/bughog/util.py +++ b/bughog/util/fs.py @@ -4,7 +4,6 @@ """ import functools -import json import logging import os import shutil @@ -12,10 +11,6 @@ import time import zipfile from typing import Optional -from urllib.parse import urlparse - -from requests import RequestException, Session -from requests.adapters import HTTPAdapter, Retry from bughog.exceptions import OutOfMemoryError @@ -96,117 +91,6 @@ def rmtree(src_path): return False -def read_web_report(file_name): - report_folder = '/reports' - path = os.path.join(report_folder, file_name) - if not os.path.isfile(path): - raise ResourceNotFound(path) - with open(path, 'r') as file: - return json.load(file) - - -def request_html(url: str): - session = __get_session() - logger.debug(f'Requesting {url}') - try: - with session.get(url, timeout=60, stream=True) as resp: - if resp.status_code >= 400: - raise ResourceNotFound(url) - return resp.content - except RequestException as e: - raise ResourceNotFound from e - - -def request_json(url: str, params: dict | None = None, token: str | None = None) -> list | dict: - session = __get_session(token=token) - logger.debug(f'Requesting {url}') - try: - with session.get(url, params=params, timeout=60, stream=True) as resp: - if resp.status_code >= 400: - raise ResourceNotFound(url) - return resp.json() - except Exception as e: - raise ResourceNotFound from e - - -def request_final_url(url: str, params: dict | None = None) -> str: - session = __get_session() - logger.debug(f'Requesting {url}') - try: - resp = session.get(url, params=params, timeout=60, stream=True) - if resp.status_code >= 400: - raise ResourceNotFound(url) - return resp.url - except RequestException as e: - raise ResourceNotFound from e - - -def post_request(url: str, json: dict) -> None: - session = __get_session() - logger.debug(f'Sending POST to {url}.') - try: - session.post(url, json=json) - except RequestException: - logger.warning(f'Could not propagate request to collector at {url}.') - - -def __get_session(token: Optional[str] = None, max_retries: int = 3, backoff_factor: int = 2) -> Session: - session = Session() - if token: - session.headers.update({'Authorization': f'Bearer {token}'}) - - retries = Retry( - total=max_retries, - backoff_factor=backoff_factor, - status_forcelist=tuple(range(500, 600)), - allowed_methods={'GET'}, - ) - adapter = HTTPAdapter(max_retries=retries) - session.mount('http://', adapter) - session.mount('https://', adapter) - return session - - -def download_and_extract(urls: list[str], dst_folder_path: str) -> bool: - """ - Downloads the archive residing at the given URL and extracts it to the given dest_path. - This method currently supports zip, tar.bz2 and tar.xz archives. - - :return bool: Returns True if the archive was successfully downloaded and extracted, otherwise False. - """ - for url in urls: - logger.debug(f"Attempting to download archive from '{url}'.") - tmp_file_name = urlparse(url).path.split('/')[-1] - tmp_file_path = os.path.join('/memory', tmp_file_name) - if os.path.exists(tmp_file_path): - os.remove(tmp_file_path) - session = __get_session() - try: - with session.get(url, stream=True) as resp: - if resp.status_code >= 400: - continue - with open(tmp_file_path, 'wb') as file: - shutil.copyfileobj(resp.raw, file) - except RequestException: - logger.debug('Download failed.') - continue - - logger.debug(f"Extracting downloaded archive '{tmp_file_path}'.") - _, file_extension = os.path.splitext(tmp_file_path) - match file_extension: - case '.zip': - unzip(tmp_file_path, dst_folder_path) - case '.bz2': - untar(tmp_file_path, dst_folder_path) - case '.xz': - untar(tmp_file_path, dst_folder_path) - case _: - AttributeError(f'File extension {file_extension} is not supported.') - os.remove(tmp_file_path) - return True - return False - - def unzip(src_archive_path: str, dst_folder_path: str) -> None: with zipfile.ZipFile(src_archive_path, 'r') as zip: members = zip.namelist() @@ -244,7 +128,3 @@ def wrapper(*args, **kwargs): return path return wrapper - - -class ResourceNotFound(Exception): - pass diff --git a/bughog/util/http.py b/bughog/util/http.py new file mode 100644 index 00000000..bd512482 --- /dev/null +++ b/bughog/util/http.py @@ -0,0 +1,153 @@ +import json +import logging +import os +import shutil +from urllib.parse import urlparse + +from requests import RequestException, Session +from requests.adapters import HTTPAdapter, Retry + +from bughog.util import fs + +logger = logging.getLogger(__name__) + + +class ResourceNotFound(Exception): + pass + + +def read_web_report(file_name): + report_folder = '/reports' + path = os.path.join(report_folder, file_name) + if not os.path.isfile(path): + raise ResourceNotFound(path) + with open(path, 'r') as file: + return json.load(file) + + +def post_request(url: str, json: dict) -> None: + session = __get_session() + logger.debug(f'Sending POST to {url}.') + try: + session.post(url, json=json) + except RequestException: + logger.warning(f'Could not propagate request to collector at {url}.') + + +def __get_session(token: str | None = None, max_retries: int = 3, backoff_factor: int = 2) -> Session: + session = Session() + if token: + session.headers.update({'Authorization': f'Bearer {token}'}) + + retries = Retry( + total=max_retries, + backoff_factor=backoff_factor, + status_forcelist=tuple(range(500, 600)), + allowed_methods={'GET'}, + ) + adapter = HTTPAdapter(max_retries=retries) + session.mount('http://', adapter) + session.mount('https://', adapter) + return session + + +def request_html(url: str): + session = __get_session() + logger.debug(f'Requesting {url}') + try: + with session.get(url, timeout=60, stream=True) as resp: + if resp.status_code >= 400: + raise ResourceNotFound(url) + return resp.content + except RequestException as e: + raise ResourceNotFound from e + + +def request_json(url: str, params: dict | None = None, token: str | None = None) -> list | dict: + session = __get_session(token=token) + logger.debug(f'Requesting {url}') + try: + with session.get(url, params=params, timeout=60, stream=True) as resp: + if resp.status_code >= 400: + raise ResourceNotFound(url) + return resp.json() + except Exception as e: + raise ResourceNotFound from e + + +def request_final_url(url: str, params: dict | None = None) -> str: + session = __get_session() + logger.debug(f'Requesting {url}') + try: + resp = session.get(url, params=params, timeout=60, stream=True) + if resp.status_code >= 400: + raise ResourceNotFound(url) + return resp.url + except RequestException as e: + raise ResourceNotFound from e + + +def __fetch(url: str) -> dict | list | None: + try: + return request_json(url) + except ResourceNotFound: + logger.warning(f'Could not fetch {url}') + return None + + +def fetch_list(url: str) -> list: + data = __fetch(url) + if data is None: + return [] + if not isinstance(data, list): + logger.warning(f'Expected a list from {url} but got {type(data)}') + return [] + return data + + +def fetch_dict(url: str) -> dict: + data = __fetch(url) + if data is None: + return {} + if not isinstance(data, dict): + logger.warning(f'Expected a dict from {url} but got {type(data)}') + return {} + return data + + +def download_and_extract(urls: list[str], dst_folder_path: str) -> bool: + """ + Downloads the archive residing at the given URL and extracts it to the given dest_path. + This method currently supports zip, tar.gz, tar.bz2 and tar.xz archives. + + :return bool: Returns True if the archive was successfully downloaded and extracted, otherwise False. + """ + for url in urls: + logger.debug(f"Attempting to download archive from '{url}'.") + tmp_file_name = urlparse(url).path.split('/')[-1] + tmp_file_path = os.path.join('/memory', tmp_file_name) + if os.path.exists(tmp_file_path): + os.remove(tmp_file_path) + session = __get_session() + try: + with session.get(url, stream=True) as resp: + if resp.status_code >= 400: + continue + with open(tmp_file_path, 'wb') as file: + shutil.copyfileobj(resp.raw, file) + except RequestException: + logger.debug('Download failed.') + continue + + logger.debug(f"Extracting downloaded archive '{tmp_file_path}'.") + _, file_extension = os.path.splitext(tmp_file_path) + match file_extension: + case '.zip': + fs.unzip(tmp_file_path, dst_folder_path) + case '.gz' | '.bz2' | '.xz': + fs.untar(tmp_file_path, dst_folder_path) + case _: + raise AttributeError(f'File extension {file_extension} is not supported.') + os.remove(tmp_file_path) + return True + return False diff --git a/bughog/version_control/conversion/bughog_service.py b/bughog/version_control/conversion/bughog_service.py index a07100f6..6397f2ea 100644 --- a/bughog/version_control/conversion/bughog_service.py +++ b/bughog/version_control/conversion/bughog_service.py @@ -4,7 +4,8 @@ from typing import Any from urllib.parse import urljoin, urlparse -from bughog.util import ResourceNotFound, request_json +from bughog.util.http import fetch_dict, fetch_list +from bughog.version_control.version import Version logger = logging.getLogger(__name__) @@ -19,13 +20,17 @@ @lru_cache(maxsize=LRU_CACHE_SIZE) def find_commit_info(subject_name: str, commit_nb: str) -> dict[str, Any]: url = urljoin(BASE_URL, f'{subject_name}/commits/{commit_nb}') - return __fetch_dict(url) + return fetch_dict(url) + + +def find_latest_commit_info(subject_name: str) -> dict[str, Any]: + return find_commit_info(subject_name, 'latest') @lru_cache(maxsize=LRU_CACHE_SIZE) def find_commit_nb(subject_name: str, commit_id: str) -> int: url = urljoin(BASE_URL, f'{subject_name}/commits/{commit_id}') - commit_info = __fetch_dict(url) + commit_info = fetch_dict(url) commit_nb = commit_info.get('nb') if commit_nb is None or not isinstance(commit_nb, int): raise Exception('BugHog service response did not include a valid commit number.') @@ -61,53 +66,51 @@ def find_nearest_commit_with_executable( BASE_URL, f'{subject_name}/commits/{target_commit_nb}/nearest_with_executable?max_lower_offset={max_lower_offset}&max_upper_offset={max_upper_offset}', ) - return __fetch_dict(url) + return fetch_dict(url) @lru_cache(maxsize=LRU_CACHE_SIZE) -def find_version_commit( - subject_name: str, major_version: int, has_public_executable: bool | None = None -) -> tuple[int, str]: +def find_version_info(subject_name: str, version: Version, has_public_executable: bool | None = None) -> dict[str, Any]: """ - We return the earliest commit associated with the given major version. - This way, the function will remain consistent as new commits associated with the same version are pushed. + Returns the earliest version entry associated with the given version. + + If the full version does not result in a valid entry, it falls back to + shorter version segments (e.g., M.m.p -> M.m -> M). """ - url = urljoin(BASE_URL, f'{subject_name}/versions/{major_version}') - if has_public_executable is not None: - url += f'?has_executable={str(has_public_executable).lower()}' - version_list = __fetch_list(url) - if len(version_list) == 0: - raise Exception('BugHog service responded with an empty list.') - commit_info = version_list[0].get('commit_info', {}) - commit_nb, commit_id = commit_info.get('nb'), commit_info.get('id') - if commit_nb is None or commit_id is None or not isinstance(commit_nb, int) or not isinstance(commit_id, str): - raise Exception('BugHog service response did not include a valid commit number and/or id.') - return commit_nb, commit_id + attempts = [str(version)] + s = 1 + while True: + v_truncated = version.truncate(s) + if v_truncated is None: + break + v_str = str(v_truncated) + if v_str not in attempts: + attempts.append(v_str) + s += 1 -@lru_cache(maxsize=LRU_CACHE_SIZE) -def find_latest_major_version(subject_name: str) -> int: - url = urljoin(BASE_URL, f'{subject_name}/versions/latest') - version_info = __fetch_dict(url) - major_version = version_info.get('major_version') - if major_version is None or not isinstance(major_version, int): - raise Exception('BugHog service response did not include a valid major version.') - return major_version + for attempt_version_str in attempts: + url = urljoin(BASE_URL, f'{subject_name}/versions/{attempt_version_str}') + if has_public_executable is not None: + url += f'?has_executable={str(has_public_executable).lower()}' + version_list = fetch_list(url) + if len(version_list) > 0: + return version_list[0] -def __fetch(url: str) -> dict | list | None: - try: - return request_json(url) - except ResourceNotFound: - logger.warning(f'Could not fetch {url}') - return None + raise Exception(f'BugHog service responded with an empty or invalid list for all version attempts: {attempts}.') -def __fetch_list(url: str) -> list: - data = __fetch(url) - return data if isinstance(data, list) else [] +@lru_cache(maxsize=LRU_CACHE_SIZE) +def find_all_versions(subject_name: str) -> list[dict]: + url = urljoin(BASE_URL, f'{subject_name}/versions') + return fetch_list(url) -def __fetch_dict(url: str) -> dict: - data = __fetch(url) - return data if isinstance(data, dict) else {} +@lru_cache(maxsize=LRU_CACHE_SIZE) +def find_latest_major_version(subject_name: str) -> Version: + url = urljoin(BASE_URL, f'{subject_name}/versions/latest') + version_str = fetch_dict(url).get('version') + if version_str is None: + raise Exception('BugHog service response did not include a valid major version.') + return Version(version_str) diff --git a/bughog/version_control/conversion/github.py b/bughog/version_control/conversion/github.py index 8906b29e..720a7e08 100644 --- a/bughog/version_control/conversion/github.py +++ b/bughog/version_control/conversion/github.py @@ -3,12 +3,12 @@ """ import logging -import os import re from datetime import datetime, timezone from typing import Optional -from bughog import util +from bughog.config import settings +from bughog.util import http from bughog.version_control.state_not_found import StateNotFound logger = logging.getLogger(__name__) @@ -16,7 +16,7 @@ def find_commit_nb(owner: str, repo: str, commit_id: str) -> int: url = f'https://api.github.com/repos/{owner}/{repo}/commits/{commit_id}' - resp = util.request_json(url, token=os.getenv('GITHUB_TOKEN')) + resp = http.request_json(url, token=settings.github_token) if not resp or not isinstance(resp, dict): raise Exception(f'Could not find commit nb for {url}.') commit_message = resp.get('commit', {}).get('message', '') @@ -26,7 +26,7 @@ def find_commit_nb(owner: str, repo: str, commit_id: str) -> int: # Get parent, where we should find the commit number parent_commit_id = resp['parents'][0]['sha'] parent_commit_url = f'https://api.github.com/repos/{owner}/{repo}/commits/{parent_commit_id}' - resp = util.request_json(parent_commit_url, token=os.getenv('GITHUB_TOKEN')) + resp = http.request_json(parent_commit_url, token=settings.github_token) if not resp or not isinstance(resp, dict): raise Exception(f'Request to {url} returned {resp}.') commit_message = resp.get('commit', {}).get('message', '') @@ -39,9 +39,9 @@ def find_commit_id_with_date(owner: str, repo: str, ts: int) -> str: """ The UNIX timestamp is considered the commit number. """ - date = datetime.fromtimestamp(ts + 1, tz=timezone.utc).isoformat().replace('+00:00','Z') + date = datetime.fromtimestamp(ts + 1, tz=timezone.utc).isoformat().replace('+00:00', 'Z') url = f'https://api.github.com/repos/{owner}/{repo}/commits?since={date}&until{date}' - resp = util.request_json(url, token=os.getenv('GITHUB_TOKEN')) + resp = http.request_json(url, token=settings.github_token) if not isinstance(resp, list): raise Exception(f'Request to {url} returned {resp}.') return resp[0].get('sha') @@ -52,7 +52,7 @@ def find_commit_nb_with_date(owner: str, repo: str, commit_id: str) -> int: The UNIX timestamp is considered the commit number. """ url = f'https://api.github.com/repos/{owner}/{repo}/commits/{commit_id}' - resp = util.request_json(url, token=os.getenv('GITHUB_TOKEN')) + resp = http.request_json(url, token=settings.github_token) if not resp or not isinstance(resp, dict): raise Exception(f'Could not find commit nb for {url}.') date = resp.get('commit', {}).get('author', {}).get('date', None) @@ -63,7 +63,7 @@ def find_commit_nb_with_date(owner: str, repo: str, commit_id: str) -> int: def find_commit_id_from_tag(owner: str, repo: str, tag: str) -> str: url = f'https://api.github.com/repos/{owner}/{repo}/git/refs/tags/{tag}' - resp = util.request_json(url, token=os.getenv('GITHUB_TOKEN')) + resp = http.request_json(url, token=settings.github_token) if not resp or not isinstance(resp, dict): raise Exception(f'Request to {url} returned {resp}.') return resp.get('object', {}).get('sha') @@ -71,7 +71,7 @@ def find_commit_id_from_tag(owner: str, repo: str, tag: str) -> str: def get_all_tags(owner: str, repo: str) -> list[str]: url = f'https://api.github.com/repos/{owner}/{repo}/git/refs/tags/' - resp = util.request_json(url, token=os.getenv('GITHUB_TOKEN')) + resp = http.request_json(url, token=settings.github_token) if not resp or not isinstance(resp, list): raise Exception(f'Request to {url} returned {resp}.') return [re.sub(r'^refs/tags/', '', item['ref']) for item in resp if 'ref' in item] @@ -79,7 +79,7 @@ def get_all_tags(owner: str, repo: str) -> list[str]: def __get_reference_commit_nb(owner: str, repo: str) -> int: url = f'https://api.github.com/repos/{owner}/{repo}/commits?page=1&per_page=1' - resp = util.request_json(url, token=os.getenv('GITHUB_TOKEN')) + resp = http.request_json(url, token=settings.github_token) if resp and isinstance(resp, list) and len(resp) > 0: commit = resp[0] commit_message = commit.get('commit', {}).get('message', '') @@ -92,8 +92,12 @@ def __get_reference_commit_nb(owner: str, repo: str) -> int: def __parse_commit_nb(commit_message: str) -> Optional[int]: if matches := re.findall(r'Cr-Commit-Position: refs/heads/(?:master|main|candidates)@\{#(\d+)\}', commit_message): return int(matches[-1]) - if matches := re.findall(r'git-svn-id: https*://v8\.googlecode\.com/svn/(?:trunk|bleeding_edge)@(\d+)', commit_message): + if matches := re.findall( + r'git-svn-id: https*://v8\.googlecode\.com/svn/(?:trunk|bleeding_edge)@(\d+)', commit_message + ): return int(matches[-1]) - if matches := re.findall(r'git-svn-id: https*://v8\.googlecode\.com/svn/branches/bleeding_edge@(\d+)', commit_message): + if matches := re.findall( + r'git-svn-id: https*://v8\.googlecode\.com/svn/branches/bleeding_edge@(\d+)', commit_message + ): return int(matches[-1]) return None diff --git a/bughog/version_control/state/base.py b/bughog/version_control/state/base.py index e3711d81..f3d0c4ee 100644 --- a/bughog/version_control/state/base.py +++ b/bughog/version_control/state/base.py @@ -8,20 +8,24 @@ from bughog.evaluation.experiment_result import ExperimentResult from bughog.subject.state_oracle import StateOracle +from bughog.version_control.version import Version @dataclass(frozen=True) class ShallowState: type: str - major_version: int | None + version: Version | None commit_nb: int | None commit_id: str | None - @property - def dict(self) -> dict: + def to_dict(self) -> dict: + # 'major_version' is always written (e.g. 120 for Chromium, 0 for Servo). + # 'version' is additionally written when the major version is 0 (e.g. 0.1.1 for Servo), + # since major_version alone is insufficient to identify the release in that case. fields = { 'type': self.type, - 'major_version': self.major_version, + 'major_version': self.version.major if self.version is not None else None, + 'version': str(self.version) if self.version is not None and self.version.major == 0 else None, 'commit_nb': self.commit_nb, 'commit_id': self.commit_id, } @@ -67,12 +71,8 @@ def has_same_outcome(self, other: State) -> bool: ) @property - def name(self) -> str: - return self.get_name(self.index) - - @staticmethod @abstractmethod - def get_name(index: int) -> str: + def name(self) -> str: pass @property @@ -111,28 +111,7 @@ def deserialize(pickled_str: str) -> State: return pickle.loads(pickled_bytes) def to_dict(self) -> dict: - return self.to_shallow_state().dict - - @staticmethod - def from_dict(subject_type: str, subject_name: str, data: dict) -> State: - from bughog.subject import factory - from bughog.version_control.state.commit_state import CommitState - from bughog.version_control.state.release_state import ReleaseState - - subject_class = factory.get_subject(subject_type, subject_name) - oracle = subject_class.state_oracle - commit_nb = data.get('commit_nb') - commit_id = data.get('commit_id') - major_version = data.get('major_version') - match data['type']: - case 'commit': - return CommitState(oracle, commit_nb=commit_nb, commit_id=commit_id) - case 'release': - if major_version is None: - raise ValueError('major_version is required for release states.') - return ReleaseState(oracle, release_version=major_version, commit_nb=commit_nb, commit_id=commit_id) - case _: - raise Exception(f'Unknown state type: {data["type"]}') + return self.to_shallow_state().to_dict() def has_available_executable(self) -> bool: return self.has_artisanal_executable() or self.has_public_executable() @@ -180,7 +159,4 @@ def __repr__(self) -> str: def __eq__(self, other: object) -> bool: if not isinstance(other, State): return False - return self.index == other.index - - def __hash__(self) -> int: - return hash(self.index) + return self.__hash__() == other.__hash__() diff --git a/bughog/version_control/state/commit_state.py b/bughog/version_control/state/commit_state.py index 46c01eec..d1706a8d 100644 --- a/bughog/version_control/state/commit_state.py +++ b/bughog/version_control/state/commit_state.py @@ -29,9 +29,9 @@ def __init__(self, oracle: StateOracle, commit_id: str | None = None, commit_nb: if self._commit_nb is not None and not self.oracle.is_valid_commit_nb(self._commit_nb): raise ValueError(f"Invalid commit number '{self._commit_nb}'.") - @staticmethod - def get_name(index: int) -> str: - return f'c_{index}' + @property + def name(self) -> str: + return f'c_{self.index}' @property def type(self) -> Literal['commit']: @@ -58,13 +58,13 @@ def to_dict(self) -> dict: return {k: v for k, v in fields.items() if v is not None} def has_public_executable(self) -> bool: - # We ignore states without a commite id. + # We ignore states without a commit id. if self.commit_id is None: return False - return self.oracle.has_public_executable(self.commit_nb, 'commit') + return self.oracle.has_public_commit_executable(self.commit_nb) def get_executable_source_urls(self) -> list[str]: - return self.oracle.get_executable_download_urls(self.commit_nb, 'commit') + return self.oracle.get_commit_executable_urls(self.commit_nb) def to_shallow_state(self) -> ShallowState: return ShallowState('commit', None, self.commit_nb, self.commit_id) @@ -74,3 +74,6 @@ def __str__(self): def __repr__(self): return f'CommitState(number: {self.commit_nb}, id: {self.commit_id})' + + def __hash__(self) -> int: + return hash((self.type, self.commit_id, self.commit_nb)) diff --git a/bughog/version_control/state/release_state.py b/bughog/version_control/state/release_state.py index 0c547a26..9ac2fbc8 100644 --- a/bughog/version_control/state/release_state.py +++ b/bughog/version_control/state/release_state.py @@ -4,23 +4,30 @@ from bughog.version_control.state.base import ShallowState, State from bughog.version_control.state.commit_state import CommitState from bughog.version_control.state_not_found import StateNotFound +from bughog.version_control.version import Version class ReleaseState(State): def __init__( - self, oracle: StateOracle, release_version: int, commit_nb: int | None = None, commit_id: str | None = None + self, + oracle: StateOracle, + release_version: Version, + index: int | None = None, + commit_nb: int | None = None, + commit_id: str | None = None, ): super().__init__(oracle) self.release_version = release_version + self._index = index if index is not None else release_version.major if commit_nb is None or commit_id is None: self._commit_nb, self.commit_id = self.oracle.find_commit_of_release(self.release_version) else: self._commit_nb = commit_nb self.commit_id = commit_id - @staticmethod - def get_name(index: int) -> str: - return f'v_{index}' + @property + def name(self) -> str: + return f'v_{str(self.release_version)}' @property def type(self) -> Literal['release']: @@ -28,7 +35,7 @@ def type(self) -> Literal['release']: @property def index(self) -> int: - return self.release_version + return self._index @property def commit_nb(self) -> int: @@ -39,10 +46,10 @@ def commit_url(self) -> Optional[str]: return None def has_public_executable(self) -> bool: - return self.oracle.has_public_executable(self.release_version, self.type) + return self.oracle.has_public_release_executable(self.release_version) def get_executable_source_urls(self) -> list[str]: - return self.oracle.get_executable_download_urls(self.release_version, self.type) + return self.oracle.get_release_executable_urls(self.release_version) def convert_to_commit_state(self) -> CommitState: try: @@ -61,7 +68,10 @@ def to_shallow_state(self) -> ShallowState: return ShallowState('release', self.release_version, self.commit_nb, self.commit_id) def __str__(self): - return f'VersionState(version: {self.release_version}, rev: {self.commit_nb})' + return f'ReleaseState(version: {self.release_version}, rev: {self.commit_nb})' def __repr__(self): - return f'VersionState(version: {self.release_version}, rev: {self.commit_nb})' + return f'ReleaseState(version: {self.release_version}, rev: {self.commit_nb})' + + def __hash__(self) -> int: + return hash((self.type, self.release_version)) diff --git a/bughog/version_control/state_factory.py b/bughog/version_control/state_factory.py index e3d32edb..a38c212e 100644 --- a/bughog/version_control/state_factory.py +++ b/bughog/version_control/state_factory.py @@ -1,82 +1,142 @@ from __future__ import annotations +from abc import ABC, abstractmethod +from typing import Iterator + from bughog.database.mongo.mongodb import MongoDB from bughog.exceptions import UserError from bughog.parameters import EvaluationParameters +from bughog.subject import factory from bughog.subject.state_oracle import StateOracle -from bughog.version_control.state.base import State +from bughog.version_control.state.base import ShallowState, State from bughog.version_control.state.commit_state import CommitState from bughog.version_control.state.release_state import ReleaseState +from bughog.version_control.version import Version -class StateFactory: - def __init__(self, state_oracle: StateOracle, eval_params: EvaluationParameters) -> None: - """ - Create a state factory object with the given evaluation parameters and boundary indices. +class StateFactory(ABC): + def __init__(self, oracle: StateOracle, eval_params: EvaluationParameters) -> None: + self._oracle = oracle + self._eval_params = eval_params + self.boundary_states: tuple[State, State] = self._create_boundary_states() - :param eval_params: The evaluation parameters. - """ - self.__oracle = state_oracle - self.__eval_params = eval_params - self.boundary_states = self.__create_boundary_states() + @abstractmethod + def _create_boundary_states(self) -> tuple[State, State]: + pass + @abstractmethod def create_state(self, index: int) -> State: - """ - Create a state object associated with the given index. - The given index represents: - - A major version number if `self.eval_params.evaluation_range.major_version_range` is True. - - A revision number otherwise. - - :param index: The index of the state. - """ - eval_range = self.__eval_params.evaluation_range - if eval_range.only_release_commits: - return self.__create_release_state(index) + pass + + @abstractmethod + def create_state_from_dict(self, state_dict: dict) -> State: + pass + + def create_evaluated_state(self, state_dict: dict) -> State: + state = self.create_state_from_dict(state_dict) + state.result_variables = set(tuple(item) for item in state_dict['result']['variables']) + state.result_attempt = state_dict['result'].get('attempt', 1) + return state + + def create_evaluated_states(self, dirty: bool | None = None) -> Iterator[State]: + for state_dict in MongoDB().get_evaluated_states(self._eval_params, self.boundary_states, dirty=dirty): + yield self.create_evaluated_state(state_dict) + + +class ReleaseStateFactory(StateFactory): + def _create_boundary_states(self) -> tuple[ReleaseState, ReleaseState]: + versions = self._eval_params.evaluation_range.versions + if not versions: + raise UserError(f'No release versions found in the given range for {self._oracle.subject_name}.') + if self._oracle.only_artisanal and self._oracle.count_artisanal_executables('release') < 2: + raise UserError(f'Not enough artisanal release executables provided for {self._oracle.subject_name}.') + return ( + self.create_state(0), + self.create_state(len(versions) - 1), + ) + + def create_state(self, index: int) -> ReleaseState: + versions = self._eval_params.evaluation_range.versions + if not versions: + raise UserError('ReleaseStateFactory requires a list of versions.') + return ReleaseState(self._oracle, versions[index], index=index) + + def create_state_from_dict(self, state_dict: dict) -> ReleaseState: + all_versions = self._eval_params.evaluation_range.versions + assert all_versions is not None, 'ReleaseStateFactory requires a list of versions.' + + state = state_dict.get('state', {}) + commit_nb = state.get('commit_nb') + commit_id = state.get('commit_id') + # Prefer 'version' (present for 0.x.y releases like Servo). Fall back to 'major_version' + # for backwards compatibility with older documents that only stored the integer major version. + raw_version = state.get('version') if state.get('version') is not None else state.get('major_version') + if raw_version is not None: + release_version = Version(str(raw_version)) else: - return self.__create_commit_state(index) - - def __create_boundary_states(self) -> tuple[State, State]: - """ - Create the boundary state objects for the evaluation range. - """ - eval_range = self.__eval_params.evaluation_range - - # Check whether the user provided enough artisanal binaries for subject types that only rely on those. - state_type = 'release' if eval_range.only_release_commits else 'commit' - if self.__oracle.only_artisanal and self.__oracle.count_artisanal_executables(state_type) < 2: - raise UserError(f'Not enough artisanal {state_type} executables provided for {self.__oracle.subject_name}.') - - if eval_range.major_version_range: - first_state = self.__create_release_state(eval_range.major_version_range[0]) - last_state = self.__create_release_state(eval_range.major_version_range[1]) - if not eval_range.only_release_commits: - first_state = first_state.convert_to_commit_state() - last_state = last_state.convert_to_commit_state() + raise ValueError('Release states must have a version.') + + index = all_versions.index(release_version) + return ReleaseState(self._oracle, release_version, index, commit_nb, commit_id) + + +class CommitStateFactory(StateFactory): + def _create_boundary_states(self) -> tuple[CommitState, CommitState]: + eval_range = self._eval_params.evaluation_range + if self._oracle.only_artisanal and self._oracle.count_artisanal_executables('commit') < 2: + raise UserError(f'Not enough artisanal commit executables provided for {self._oracle.subject_name}.') + if eval_range.versions: + nb_of_versions = len(eval_range.versions) + first_state = ReleaseState(self._oracle, eval_range.versions[0], index=0).convert_to_commit_state() + last_release = ReleaseState(self._oracle, eval_range.versions[-1], index=nb_of_versions - 1) + if self._oracle.get_latest_supported_release_version().major == last_release.index: + last_state = CommitState(self._oracle, commit_nb=self._oracle.get_most_recent_commit_nb()) + else: + last_state = last_release.convert_to_commit_state() + return first_state, last_state + elif eval_range.version_range: + first_state = ReleaseState(self._oracle, eval_range.version_range[0]).convert_to_commit_state() + last_release = ReleaseState(self._oracle, eval_range.version_range[1]) + if self._oracle.get_latest_supported_release_version().major == last_release.index: + last_state = CommitState(self._oracle, commit_nb=self._oracle.get_most_recent_commit_nb()) + else: + last_state = last_release.convert_to_commit_state() return first_state, last_state elif eval_range.commit_nb_range: - if eval_range.only_release_commits: - raise ValueError('Release revisions are not allowed in this evaluation range') return ( - self.__create_commit_state(eval_range.commit_nb_range[0]), - self.__create_commit_state(eval_range.commit_nb_range[1]), + CommitState(self._oracle, commit_nb=eval_range.commit_nb_range[0]), + CommitState(self._oracle, commit_nb=eval_range.commit_nb_range[1]), ) - else: - raise ValueError('No evaluation range specified') - - def create_evaluated_states(self) -> list[State]: - """ - Create evaluated state objects within the evaluation range where the result is fetched from the database. - """ - return MongoDB().get_evaluated_states(self.__eval_params, self.boundary_states) - - def __create_release_state(self, index: int) -> ReleaseState: - """ - Create a version state object associated with the given index. - """ - return ReleaseState(self.__oracle, index) - - def __create_commit_state(self, index: int) -> CommitState: - """ - Create a revision state object associated with the given index. - """ - return CommitState(self.__oracle, commit_nb=index) + raise ValueError('CommitStateFactory requires a version_range or commit_nb_range.') + + def create_state(self, index: int) -> CommitState: + return CommitState(self._oracle, commit_nb=index) + + def create_state_from_dict(self, state_dict: dict) -> CommitState: + commit_nb = state_dict.get('state', {}).get('commit_nb') + commit_id = state_dict.get('state', {}).get('commit_id') + return CommitState(self._oracle, commit_nb=commit_nb, commit_id=commit_id) + + +def create_state_factory(eval_params: EvaluationParameters) -> StateFactory: + subject = factory.get_subject_from_params(eval_params.subject_configuration) + if eval_params.evaluation_range.only_release_commits: + return ReleaseStateFactory(subject.state_oracle, eval_params) + return CommitStateFactory(subject.state_oracle, eval_params) + + +def create_state_from_shallow_state(shallow_state: ShallowState, subject_type: str, subject_name: str) -> State: + subject_class = factory.get_subject(subject_type, subject_name) + oracle = subject_class.state_oracle + commit_nb = shallow_state.commit_nb + commit_id = shallow_state.commit_id + match shallow_state.type: + case 'commit': + return CommitState(oracle, commit_nb=commit_nb, commit_id=commit_id) + case 'release': + version = shallow_state.version + if version is None: + raise ValueError('Release states must have a version.') + return ReleaseState(oracle, version, commit_nb=commit_nb, commit_id=commit_id) + case _: + raise Exception(f'Unknown state type: {shallow_state.type}') diff --git a/bughog/version_control/version.py b/bughog/version_control/version.py new file mode 100644 index 00000000..ece22db2 --- /dev/null +++ b/bughog/version_control/version.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from functools import total_ordering +from typing import Any + +from packaging.version import parse + + +@total_ordering +class Version: + """ + A wrapper around packaging.version.Version to provide BugHog-specific + logic like 'selectable_version' and consistent padding. + """ + + def __init__(self, version_str_or_int: str | int): + version_str = str(version_str_or_int) + self._original_str = version_str + # Handle formats like "0.0.1-" (Servo). + # PEP 440 local versions use '+' (e.g. 0.0.1+hash). + parsed_str = version_str + if '-' in version_str: + base, suffix = version_str.split('-', 1) + parsed_str = f'{base}+{suffix}' + + self._v = parse(parsed_str) + + @property + def major(self) -> int: + return self._v.major + + @property + def minor(self) -> int: + return self._v.minor + + @property + def patch(self) -> int: + return self._v.micro + + @property + def base_version(self) -> str: + return self._v.base_version + + @property + def is_pre_release(self) -> bool: + return self.major == 0 + + @property + def selectable_version(self) -> str: + """ + Returns the version string that should be used for selection in the UI/API. + - For major versions >= 1, returns the major version (e.g. "100"). + - For major versions == 0 and minor >= 1, returns major.minor (e.g. "0.1"). + - For major versions == 0 and minor == 0, returns major.minor.patch (e.g. "0.0.1"). + """ + if self.major >= 1: + return str(self.major) + if self.minor >= 1: + return f'{self.major}.{self.minor}' + return f'{self.major}.{self.minor}.{self.patch}' + + def padded(self, segment_length: int = 4) -> str: + """ + Returns a zero-padded version string suitable for lexicographic comparison. + Matches the logic in ExperimentResult.padded_subject_version. + """ + padded_segments = [] + for s in self._v.release: + s_str = str(s) + if len(s_str) > segment_length: + raise ValueError(f"Version segment '{s_str}' exceeds maximum length of {segment_length}") + padded_segments.append(s_str.zfill(segment_length)) + + padded_base = '.'.join(padded_segments) + + if self._v.local: + return f'{padded_base}-{self._v.local}' + return padded_base + + def next_padded(self, segment_length: int = 4) -> str: + """ + Returns the padded version string of the next version (last segment incremented by 1), + suitable as an exclusive upper bound in lexicographic range queries. + Example: Version('0.0.6').next_padded() == '0000.0000.0007' + Version('145').next_padded() == '0146' + """ + release = list(self._v.release) + release[-1] += 1 + return '.'.join(str(s).zfill(segment_length) for s in release) + + def matches(self, other: Any) -> bool: + """ + Returns True if this version 'could' be the other version. + This is primarily a prefix match on version segments. + Example: + - Version('12').matches(Version('12.0.1')) -> True + - Version('13.1').matches(Version('13.1.2')) -> True + - Version('13.1').matches(Version('13.2')) -> False + """ + if not isinstance(other, Version): + return NotImplemented + + if len(self._v.release) <= len(other._v.release): + if other._v.release[: len(self._v.release)] != self._v.release: + return False + else: + if self != other: + return False + + if self._v.local is not None: + return self._v.local == other._v.local + + return True + + def truncate(self, segments: int = 1) -> Version | None: + """ + Returns a new Version truncated by the specified number of segments. + Example: + - Version('12.0.1').truncate(1) -> Version('12.0') + - Version('12.0.1').truncate(2) -> Version('12') + - Version('12.0.1').truncate(3) -> None + """ + if segments < 1: + raise ValueError('Segments must be at least 1') + + keep = len(self._v.release) - segments + if keep <= 0: + return None + + truncated_release = self._v.release[:keep] + truncated_version_str = '.'.join(str(s) for s in truncated_release) + return Version(truncated_version_str) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Version): + return NotImplemented + return self._v == other._v + + def __hash__(self) -> int: + return hash(self._v) + + def __lt__(self, other: Any) -> bool: + if not isinstance(other, Version): + return NotImplemented + return self._v < other._v + + def __repr__(self) -> str: + return f"Version('{self._original_str}')" + + def __str__(self) -> str: + return self._original_str diff --git a/bughog/web/blueprints/api.py b/bughog/web/blueprints/api.py index 8c431ff8..f97eac38 100644 --- a/bughog/web/blueprints/api.py +++ b/bughog/web/blueprints/api.py @@ -5,7 +5,7 @@ from flask import Blueprint, current_app, redirect, request import bughog.parameters as application_logic -from bughog import configuration +from bughog import config from bughog.app import sock from bughog.database.mongo.mongodb import MongoDB from bughog.integration_tests import evaluation_configurations, verify_results @@ -13,9 +13,8 @@ from bughog.parameters import MissingParametersError from bughog.subject import factory from bughog.subject.factory import get_all_subject_availability -from bughog.version_control.state.base import ShallowState from bughog.web.clients import Clients -from bughog.web.evaluation_thread import run_eval_thread +from bughog.web.evaluation_thread import run_eval_thread, run_experiment_thread logger = logging.getLogger(__name__) api = Blueprint('api', __name__, url_prefix='/api') @@ -34,7 +33,7 @@ def check_readiness(): # _ = ____get_main() except Exception as e: logger.critical(e) - return {'status': 'NOK', 'msg': 'BugHog is not ready', 'info': {'log': configuration.Loggers.get_logs()}} + return {'status': 'NOK', 'msg': 'BugHog is not ready', 'info': {'log': config.Loggers.get_logs()}} @api.after_request @@ -58,8 +57,8 @@ def start_evaluation(): data = request.json.copy() try: - database_params = configuration.get_database_params() - params = application_logic.evaluation_factory(data, database_params) + database_params = config.get_database_params() + params = application_logic.create_evaluation_params(data, database_params) run_eval_thread(__get_main(), params) return {'status': 'OK'} except MissingParametersError: @@ -80,6 +79,37 @@ def stop_evaluation(): return {'status': 'OK'} +@api.route('/experiment/start/', methods=['POST']) +def start_experiment(): + if request.json is None: + return {'status': 'NOK', 'msg': 'No experiment parameters found'} + + data = request.json.copy() + try: + database_params = config.get_database_params() + params = application_logic.create_experiment_params(data, database_params) + __get_main().remove_datapoint(params) + run_experiment_thread(__get_main(), params) + return {'status': 'OK'} + except MissingParametersError: + return {'status': 'NOK', 'msg': 'Could not start experiment due to missing parameters.'} + + +@api.route('/experiment/remove/', methods=['POST']) +def remove_experiment_result(): + if request.json is None: + return {'status': 'NOK', 'msg': 'No experiment parameters found'} + + data = request.json.copy() + try: + database_params = config.get_database_params() + params = application_logic.create_experiment_params(data, database_params) + __get_main().remove_datapoint(params) + return {'status': 'OK'} + except MissingParametersError: + return {'status': 'NOK', 'msg': 'Could not remove experiment result due to missing parameters.'} + + """ Requesting information """ @@ -100,6 +130,9 @@ def init_websocket(ws): Clients.associate_params(ws, params) if requested_variables := message.get('get', []): __get_main().push_info(ws, *requested_variables) + if params_dict := message.get('request_experiment_result', None): + params = application_logic.create_experiment_params(params_dict, config.get_database_params()) + Clients.push_complete_experiment_result(params) except ValueError: logger.warning('Ignoring invalid message from client.') @@ -109,6 +142,13 @@ def get_subjects(): return {'status': 'OK', 'subject_availability': get_all_subject_availability()} +@api.route('/subject//versions/', methods=['GET']) +def get_subject_versions(subject_name: str): + from bughog.version_control.conversion import bughog_service + versions = bughog_service.find_all_versions(subject_name) + return {'status': 'OK', 'versions': versions} + + @api.route('/system/', methods=['GET']) def get_system_info(): return {'status': 'OK', 'cpu_count': os.cpu_count() if os.cpu_count() else 2} @@ -201,7 +241,7 @@ def add_folder_or_file(subject_type: str, project: str, poc: str): @api.route('/poc/domain/', methods=['GET']) def get_available_domains(): - return {'status': 'OK', 'domains': configuration.get_available_domains()} + return {'status': 'OK', 'domains': config.get_available_domains()} @api.route('/poc///', methods=['POST']) @@ -229,15 +269,12 @@ def remove_datapoint(): data = request.json.copy() if not isinstance(data, dict): return {'status': 'NOK', 'msg': 'Received dataformat is not a dictionary.'} - if (type := data.get('type')) not in ['release', 'commit']: + if (data.get('type')) not in ['release', 'commit']: return {'status': 'NOK', 'msg': 'Type argument should be release or commit.'} - database_params = configuration.get_database_params() + database_params = config.get_database_params() try: - params_list = application_logic.evaluation_factory(data, database_params, only_to_plot=True) - if len(params_list) < 1: - return {'status': 'NOK', 'msg': 'Could not construct removal parameters.'} - state = ShallowState(type, data.get('major_version'), data.get('commit_nb'), data.get('commit_id')) - __get_main().remove_datapoint(params_list[0], state) + params = application_logic.create_experiment_params(data, database_params) + __get_main().remove_datapoint(params) except MissingParametersError: return {'status': 'NOK', 'msg': 'Could not remove datapoint due to missing parameters'} return {'status': 'OK'} diff --git a/bughog/web/blueprints/experiments.py b/bughog/web/blueprints/experiments.py index 55b40b4c..da3187bf 100644 --- a/bughog/web/blueprints/experiments.py +++ b/bughog/web/blueprints/experiments.py @@ -3,13 +3,15 @@ import logging import sys import threading +from typing import Any -from bughog import util -from bughog.evaluation.experiments import SUPPORTED_DOMAINS from flask import Blueprint, Request, make_response, render_template, request, url_for +from bughog.evaluation.experiments import SUPPORTED_DOMAINS +from bughog.util import http + logger = logging.getLogger(__name__) -exp = Blueprint("experiments", __name__, template_folder="/app/bughog/web/templates") +exp = Blueprint('experiments', __name__, template_folder='/app/bughog/web/templates') @exp.before_request @@ -17,9 +19,7 @@ def before_request(): __report(request) host = request.host.lower() if host not in SUPPORTED_DOMAINS: - logger.error( - f"Host '{host}' is not supported by this framework. Supported hosts are {SUPPORTED_DOMAINS}" - ) + logger.error(f"Host '{host}' is not supported by this framework. Supported hosts are {SUPPORTED_DOMAINS}") return f"Host '{host}' is not supported by this framework." @@ -29,130 +29,133 @@ def __report(request: Request) -> None: """ # Respond to collector on same IP # remote_ip = request.remote_addr - remote_ip = request.headers.get("X-Real-IP") + remote_ip = request.headers.get('X-Real-IP') response_data = { - "url": request.url, - "method": request.method, - "headers": dict(request.headers), - "content": request.data.decode("utf-8"), + 'url': request.url, + 'method': request.method, + 'headers': dict(request.headers), + 'content': request.data.decode('utf-8'), } def send_report_to_collector(): - util.post_request(f'http://{remote_ip}:5001/report/', response_data) + http.post_request(f'http://{remote_ip}:5001/report/', response_data) threading.Thread(target=send_report_to_collector).start() -def __get_all_GET_parameters(request) -> dict[str,str]: +def __get_all_GET_parameters(request) -> dict[str, Any]: return {k: v for k, v in request.args.items()} -@exp.route("/") +@exp.route('/') def index(): - return f"This page is visited over {request.scheme}." + return f'This page is visited over {request.scheme}.' -@exp.route("/report/", methods=["GET", "POST"]) +@exp.route('/report/', methods=['GET', 'POST']) def report_endpoint(): get_params = [item for item in __get_all_GET_parameters(request).items()] - resp = make_response( - render_template("cookies.html", title="Report", get_params=get_params) - ) + resp = make_response(render_template('cookies.html', title='Report', get_params=get_params)) cookie_exp_date = datetime.datetime.now() + datetime.timedelta(weeks=4) - resp.set_cookie("generic", "1", expires=cookie_exp_date) - resp.set_cookie("secure", "1", expires=cookie_exp_date, secure=True) - resp.set_cookie("httpOnly", "1", expires=cookie_exp_date, httponly=True) - resp.set_cookie("lax", "1", expires=cookie_exp_date, samesite="lax") - resp.set_cookie("strict", "1", expires=cookie_exp_date, samesite="strict") + resp.set_cookie('generic', '1', expires=cookie_exp_date) + resp.set_cookie('secure', '1', expires=cookie_exp_date, secure=True) + resp.set_cookie('httpOnly', '1', expires=cookie_exp_date, httponly=True) + resp.set_cookie('lax', '1', expires=cookie_exp_date, samesite='lax') + resp.set_cookie('strict', '1', expires=cookie_exp_date, samesite='strict') return resp -@exp.route("/report/if/scheme//") +@exp.route('/report/if/scheme//') def report_leak_if_using_http(target_scheme): """ Triggers request to /report/ if a request was received over the specified `scheme`. """ - used_scheme = request.headers.get("X-Forwarded-Proto") + used_scheme = request.headers.get('X-Forwarded-Proto') params = __get_all_GET_parameters(request) if used_scheme == target_scheme: - return "Redirect", 307, {"Location": url_for("experiments.report_endpoint", **params)} + return 'Redirect', 307, {'Location': url_for('experiments.report_endpoint', **params)} else: - return f"Request was received over {used_scheme}, instead of {target_scheme}", 200, {} + return f'Request was received over {used_scheme}, instead of {target_scheme}', 200, {} -@exp.route("/report/if//") +@exp.route('/report/if//') def report_leak_if_present(expected_header_name: str): """ Triggers request to /report/ if a request header by name of `expected_header_name` was received. """ if expected_header_name not in request.headers: - return f"Header {expected_header_name} not found", 200, {"Allow-CSP-From": "*"} + return f'Header {expected_header_name} not found', 200, {'Allow-CSP-From': '*'} params = __get_all_GET_parameters(request) return ( - "Redirect", + 'Redirect', 307, { - "Location": url_for("experiments.report_endpoint", **params), - "Allow-CSP-From": "*", + 'Location': url_for('experiments.report_endpoint', **params), + 'Allow-CSP-From': '*', }, ) -@exp.route("/report/if//contains//") +@exp.route('/report/if//contains//') def report_leak_if_contains(expected_header_name: str, expected_header_value: str): """ Triggers request to /report/ if a request header `expected_header_name` with value `expected_header_value` was received. """ if expected_header_name not in request.headers: - return f"Header {expected_header_name} not found", 200, {"Allow-CSP-From": "*"} + return f'Header {expected_header_name} not found', 200, {'Allow-CSP-From': '*'} elif expected_header_value not in request.headers[expected_header_name]: return ( f"Header {expected_header_name} found, but expected value '{expected_header_value}' was not found in the actual value '{request.headers[expected_header_name]}'", 200, - {"Allow-CSP-From": "*"}, + {'Allow-CSP-From': '*'}, ) params = __get_all_GET_parameters(request) return ( - "Redirect", + 'Redirect', 307, { - "Location": url_for("experiments.report_endpoint", **params), - "Allow-CSP-From": "*", + 'Location': url_for('experiments.report_endpoint', **params), + 'Allow-CSP-From': '*', }, ) -@exp.route("///.py", methods=["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"]) +@exp.route( + '///.py', + methods=['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH'], +) def python_evaluation(project: str, experiment: str, file_name: str): """ Evaluates the python script and returns its result. """ host = request.host.lower() - module_name = f"{host}/{project}/{experiment}" - path = f"/app/subject/web_browser/experiments/{project}/{experiment}/{file_name}.py" + module_name = f'{host}/{project}/{experiment}' + path = f'/app/subject/web_browser/experiments/{project}/{experiment}/{file_name}.py' # Dynamically import the file sys.dont_write_bytecode = True spec = importlib.util.spec_from_file_location(module_name, path) + assert spec is not None module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module + assert spec.loader is not None spec.loader.exec_module(module) def reproduced() -> None: - remote_ip = request.headers.get("X-Real-IP") + remote_ip = request.headers.get('X-Real-IP') response_data = { - "url": url_for("experiments.report_endpoint", bughog_reproduced='OK'), - "method": request.method, - "headers": dict(request.headers), - "content": request.data.decode("utf-8"), + 'url': url_for('experiments.report_endpoint', bughog_reproduced='OK'), + 'method': request.method, + 'headers': dict(request.headers), + 'content': request.data.decode('utf-8'), } def send_report_to_collector(): - util.post_request(f'http://{remote_ip}:5001/report/', response_data) + http.post_request(f'http://{remote_ip}:5001/report/', response_data) threading.Thread(target=send_report_to_collector).start() diff --git a/bughog/web/clients.py b/bughog/web/clients.py index eddbc566..3ba2a5c3 100644 --- a/bughog/web/clients.py +++ b/bughog/web/clients.py @@ -5,9 +5,10 @@ from simple_websocket import Server -from bughog import configuration +from bughog import config from bughog.analysis.plot_factory import PlotFactory -from bughog.parameters import MissingParametersError, evaluation_factory +from bughog.database.mongo.mongodb import MongoDB +from bughog.parameters import ExperimentParameters, MissingParametersError, create_evaluation_params from bughog.subject import factory logger = logging.getLogger(__name__) @@ -62,7 +63,7 @@ def push_results(ws_client: Server): return params['experiments'] = [params['experiment_to_plot']] try: - eval_params = evaluation_factory(params, configuration.get_database_params()) + eval_params = create_evaluation_params(params, config.get_database_params()) if len(eval_params) < 1: return plot_params = eval_params[0].to_plot_parameters(params['experiment_to_plot']) @@ -79,6 +80,9 @@ def push_results(ws_client: Server): { 'update': { 'plot_data': { + 'subject_name': params.get('subject_name'), + 'project_name': params.get('project_name'), + 'experiment_name': params.get('experiment_to_plot'), 'revision_data': revision_data, 'version_data': version_data, } @@ -131,3 +135,20 @@ def push_notification_to_all(message: str, type: Literal['info', 'error'] = 'inf Clients.__remove_disconnected_clients() for ws_client in Clients.__clients.keys(): ws_client.send(json.dumps({'notification': {'message': message, 'type': type}})) + + @staticmethod + def push_complete_experiment_result(params: ExperimentParameters) -> None: + Clients.__remove_disconnected_clients() + + if params is None: + logger.error('Could not find any associated parameters for this client.') + return + result = MongoDB().get_result(params) + + if result is None: + data = json.dumps({'update': {'experiment_result': None}}) + else: + data = json.dumps({'update': {'experiment_result': result.to_dict()}}) + + for ws_client in Clients.__clients.keys(): + ws_client.send(data) diff --git a/bughog/web/evaluation_thread.py b/bughog/web/evaluation_thread.py index 3c017efe..487424d9 100644 --- a/bughog/web/evaluation_thread.py +++ b/bughog/web/evaluation_thread.py @@ -2,7 +2,7 @@ import threading from bughog.main import Main -from bughog.parameters import EvaluationParameters +from bughog.parameters import EvaluationParameters, ExperimentParameters from bughog.web.clients import Clients logger = logging.getLogger(__name__) @@ -24,3 +24,19 @@ def thread_wrapper(): else: THREAD = threading.Thread(target=thread_wrapper) THREAD.start() + + +def run_experiment_thread(main: Main, experiment_params: ExperimentParameters): + global THREAD + + def thread_wrapper(): + try: + main.run_single_experiment(experiment_params) + except Exception as e: + Clients.push_notification_to_all(str(e), type='error') + + if THREAD and THREAD.is_alive(): + Clients.push_notification_to_all('Experiment thread is already running.', type='error') + else: + THREAD = threading.Thread(target=thread_wrapper) + THREAD.start() diff --git a/bughog/web/vue/package-lock.json b/bughog/web/vue/package-lock.json index e370e2ff..5dc660b7 100644 --- a/bughog/web/vue/package-lock.json +++ b/bughog/web/vue/package-lock.json @@ -9,28 +9,27 @@ "version": "0.0.0", "dependencies": { "@vueform/slider": "^2.1.10", - "@vueuse/core": "^13.9.0", + "@vueuse/core": "^14.2.1", "ace-builds": "^1.43.4", "axios": "^1.12.2", - "flowbite": "^1.8.1", + "flowbite": "^4.0.1", "oh-vue-icons": "^1.0.0-rc3", "vue": "^3.2.47", - "vue-multiselect": "^2.1.9", + "vue-multiselect": "^3.4.0", + "vue-router": "^5.0.3", "vue3-toastify": "^0.2.8" }, "devDependencies": { - "@vitejs/plugin-vue": "^4.6.2", - "autoprefixer": "^10.4.21", - "postcss": "^8.5.6", - "tailwindcss": "^3.4.18", - "vite": "^4.5.14" + "@tailwindcss/vite": "^4.2.1", + "@vitejs/plugin-vue": "^6.0.4", + "tailwindcss": "^4.2.1", + "vite": "^7.3.1" } }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -39,6 +38,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@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-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -58,12 +73,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -73,9 +88,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -85,10 +100,27 @@ "node": ">=6.9.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -99,13 +131,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -116,13 +148,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -133,13 +165,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -150,13 +182,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -167,13 +199,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -184,13 +216,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -201,13 +233,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -218,13 +250,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -235,13 +267,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -252,13 +284,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -269,13 +301,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -286,13 +318,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -303,13 +335,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -320,13 +352,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -337,13 +369,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -354,13 +386,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -371,13 +420,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -388,13 +454,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -405,13 +488,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -422,13 +505,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -439,13 +522,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -456,25 +539,33 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/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==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -490,51 +581,12 @@ "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -545,408 +597,990 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, "license": "MIT" }, - "node_modules/@vitejs/plugin-vue": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", - "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==", - "dev": true, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">=14.0.0" }, "peerDependencies": { - "vite": "^4.0.0 || ^5.0.0", - "vue": "^3.2.25" + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } } }, - "node_modules/@vue/compiler-core": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", - "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.27", - "entities": "^7.0.0", + "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } } }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", - "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.27", - "@vue/shared": "3.5.27" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", - "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/compiler-core": "3.5.27", - "@vue/compiler-dom": "3.5.27", - "@vue/compiler-ssr": "3.5.27", - "@vue/shared": "3.5.27", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.6", - "source-map-js": "^1.2.1" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", - "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.27", - "@vue/shared": "3.5.27" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@vue/reactivity": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", - "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.27" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@vue/runtime-core": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz", - "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.27", - "@vue/shared": "3.5.27" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", - "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.27", - "@vue/runtime-core": "3.5.27", - "@vue/shared": "3.5.27", - "csstype": "^3.2.3" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@vue/server-renderer": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz", - "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.27", - "@vue/shared": "3.5.27" - }, - "peerDependencies": { - "vue": "3.5.27" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", - "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", - "license": "MIT" + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@vueform/slider": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@vueform/slider/-/slider-2.1.10.tgz", - "integrity": "sha512-L2G3Ju51Yq6yWF2wzYYsicUUaH56kL1QKGVtimUVHT1K1ADcRT94xVyIeJpS0klliVEeF6iMZFbdXtHq8AsDHw==", - "license": "MIT" + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@vueuse/core": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz", - "integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "13.9.0", - "@vueuse/shared": "13.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vue": "^3.5.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@vueuse/metadata": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz", - "integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@vueuse/shared": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz", - "integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vue": "^3.5.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/ace-builds": { - "version": "1.43.5", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.5.tgz", - "integrity": "sha512-iH5FLBKdB7SVn9GR37UgA/tpQS8OTWIxWAuq3Ofaw+Qbc69FfPXsXd9jeW7KRG2xKpKMqBDnu0tHBrCWY5QI7A==", - "license": "BSD-3-Clause" + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" }, "engines": { - "node": ">= 8" + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz", + "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "postcss": "^8.5.6", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "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==", "license": "MIT" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, - "node_modules/autoprefixer": { - "version": "10.4.23", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", - "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", + "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" + "@rolldown/pluginutils": "1.0.0-rc.13" }, - "bin": { - "autoprefixer": "bin/autoprefixer" + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" }, "peerDependencies": { - "postcss": "^8.1.0" + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } } }, - "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz", - "integrity": "sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.1.tgz", + "integrity": "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.1.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.1.tgz", + "integrity": "sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.1.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", + "integrity": "sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "vue": "3.5.34" } }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "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" - } - ], + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/@vueform/slider": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@vueform/slider/-/slider-2.1.10.tgz", + "integrity": "sha512-L2G3Ju51Yq6yWF2wzYYsicUUaH56kL1QKGVtimUVHT1K1ADcRT94xVyIeJpS0klliVEeF6iMZFbdXtHq8AsDHw==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/ace-builds": { + "version": "1.43.6", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.6.tgz", + "integrity": "sha512-L1ddibQ7F3vyXR2k2fg+I8TQTPWVA6CKeDQr/h2+8CeyTp3W6EQL8xNFZRTztuP8xNOAqL3IYPqdzs31GCjDvg==", + "license": "BSD-3-Clause" + }, + "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==", + "license": "MIT", "bin": { - "browserslist": "cli.js" + "acorn": "bin/acorn" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">=0.4.0" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" }, "engines": { - "node": ">= 0.4" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, "engines": { - "node": ">= 6" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001765", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", - "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", - "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/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" }, "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "node": ">= 0.4" } }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 6" + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/combined-stream": { @@ -961,28 +1595,11 @@ "node": ">= 0.8" } }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" }, "node_modules/csstype": { "version": "3.2.3", @@ -990,6 +1607,15 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -999,19 +1625,14 @@ "node": ">=0.4.0" } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" + "node_modules/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/dunder-proto": { "version": "1.0.1", @@ -1027,17 +1648,23 @@ "node": ">= 0.4" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, - "license": "ISC" + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } }, "node_modules/entities": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", - "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -1092,9 +1719,9 @@ } }, "node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1102,41 +1729,35 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "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" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/estree-walker": { @@ -1145,73 +1766,56 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "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", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" + "node": ">=12.0.0" }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, + "node_modules/flowbite": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-4.0.1.tgz", + "integrity": "sha512-UwUjvnqrQTiFm3uMJ0WWnzKXKoDyNyfyEzoNnxmZo6KyDzCedjqZw1UW0Oqdn+E0iYVdPu0fizydJN6e4pP9Rw==", "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" + "@popperjs/core": "^2.9.3", + "flowbite-datepicker": "^2.0.0", + "mini-svg-data-uri": "^1.4.3", + "postcss": "^8.5.1", + "tailwindcss": "^4.1.12" } }, - "node_modules/flowbite": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-1.8.1.tgz", - "integrity": "sha512-lXTcO8a6dRTPFpINyOLcATCN/pK1Of/jY4PryklPllAiqH64tSDUsOdQpar3TO59ZXWwugm2e92oaqwH6X90Xg==", + "node_modules/flowbite-datepicker": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/flowbite-datepicker/-/flowbite-datepicker-2.0.0.tgz", + "integrity": "sha512-m81hl0Bimq45MUg4maJLOnXrX+C9lZ0AkjMb9uotuVUSr729k/YiymWDfVAm63AYDH7g7y3rI3ke3XaBzWWqLw==", "license": "MIT", "dependencies": { - "@popperjs/core": "^2.9.3", - "mini-svg-data-uri": "^1.4.3" + "@rollup/plugin-node-resolve": "^15.2.3", + "@tailwindcss/postcss": "^4.1.17" } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -1244,25 +1848,10 @@ "node": ">= 6" } }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1319,162 +1908,389 @@ "node": ">= 0.4" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, + "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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "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": { - "is-glob": "^4.0.3" + "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": ">=10.13.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", + "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": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", + "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": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, + "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": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, + "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": ">= 0.4" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" + "node": ">= 12.0.0" }, - "engines": { - "node": ">=8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, + "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": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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", + "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": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", "engines": { "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1484,6 +2300,21 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1493,30 +2324,6 @@ "node": ">= 0.4" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -1547,22 +2354,45 @@ "mini-svg-data-uri": "cli.js" } }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", "license": "MIT", "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" } }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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", @@ -1577,43 +2407,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/oh-vue-icons": { "version": "1.0.0-rc3", "resolved": "https://registry.npmjs.org/oh-vue-icons/-/oh-vue-icons-1.0.0-rc3.tgz", @@ -1662,173 +2455,62 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "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", - "dependencies": { - "camelcase-css": "^2.0.1" - }, "engines": { - "node": "^12 || ^14 || >= 16" + "node": ">=12" }, - "peerDependencies": { - "postcss": "^8.4.21" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", "license": "MIT", "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" } }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "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" @@ -1836,93 +2518,59 @@ ], "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.1.1" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" + "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, "engines": { - "node": ">=4" + "node": ">=10" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "type": "individual", + "url": "https://github.com/sponsors/antfu" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "individual", + "url": "https://github.com/sponsors/sxzz" } ], "license": "MIT" }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, "engines": { - "node": ">=8.10.0" + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -1937,57 +2585,63 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { - "version": "3.29.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", - "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", - "dev": true, + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "devOptional": true, "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, "bin": { "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=14.18.0", + "node": ">=18.0.0", "npm": ">=8.0.0" }, "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" }, "node_modules/source-map-js": { "version": "1.2.1", @@ -1998,34 +2652,10 @@ "node": ">=0.10.0" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2035,75 +2665,32 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "license": "MIT" }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, + "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", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, "engines": { - "node": ">=0.8" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "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.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2112,131 +2699,88 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" }, "engines": { - "node": ">=8.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/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" - } - ], + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", "license": "MIT", "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" + "pathe": "^2.0.3", + "picomatch": "^4.0.3" }, - "bin": { - "update-browserslist-db": "cli.js" + "engines": { + "node": ">=20.19.0" }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "funding": { + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, "node_modules/vite": { - "version": "4.5.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", - "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.18.10", - "postcss": "^8.4.27", - "rollup": "^3.27.1" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": ">= 14", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.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 }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -2246,6 +2790,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -2254,20 +2801,26 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, "node_modules/vue": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", - "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.27", - "@vue/compiler-sfc": "3.5.27", - "@vue/runtime-dom": "3.5.27", - "@vue/server-renderer": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" }, "peerDependencies": { "typescript": "*" @@ -2279,19 +2832,64 @@ } }, "node_modules/vue-multiselect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-2.1.9.tgz", - "integrity": "sha512-nGEppmzhQQT2iDz4cl+ZCX3BpeNhygK50zWFTIRS+r7K7i61uWXJWSioMuf+V/161EPQjexI8NaEBdUlF3dp+g==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-3.5.0.tgz", + "integrity": "sha512-i758SEqWFcFshL1eAg0F3EFeFQ1mOCmh2mgnGCZv1XpHFVIAv8fxo8bQQ4ZnMoaPhMp8KI1A6gPBVHh3YzRg/Q==", "license": "MIT", "engines": { - "node": ">= 4.0.0", - "npm": ">= 3.0.0" + "node": ">= 14.18.1", + "npm": ">= 6.14.15" + } + }, + "node_modules/vue-router": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.6.tgz", + "integrity": "sha512-9+kmUTGbKMyW9Asoy98IXXYIzrTMT7JDAdpDDeEkorHvybpUvBI2wsrSM5jFOXrFydpzRFJ9vAh+80DN2PGu9w==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } } }, "node_modules/vue3-toastify": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/vue3-toastify/-/vue3-toastify-0.2.8.tgz", - "integrity": "sha512-8jDOqsJaBZEbGpCbhWDETJc11D1lZefvgFPq/IPdM+U7+qyXoVPDvK6uq/FIgyV7qV0NcNzvGBMEzjsLQqGROw==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/vue3-toastify/-/vue3-toastify-0.2.9.tgz", + "integrity": "sha512-LN2GJGKgjt+C6IIBANp9hCM2A4yc5jC2Kj4YGl1J7ptj4rhThOJOGGlkCg+IxDt7hntNw9n3MG9ifv/AymyxKQ==", "license": "MIT", "engines": { "node": ">=20", @@ -2305,6 +2903,27 @@ "optional": true } } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/bughog/web/vue/package.json b/bughog/web/vue/package.json index 6ae3ab9e..34b0db9b 100644 --- a/bughog/web/vue/package.json +++ b/bughog/web/vue/package.json @@ -10,20 +10,20 @@ }, "dependencies": { "@vueform/slider": "^2.1.10", - "@vueuse/core": "^13.9.0", + "@vueuse/core": "^14.2.1", "ace-builds": "^1.43.4", "axios": "^1.12.2", - "flowbite": "^1.8.1", + "flowbite": "^4.0.1", "oh-vue-icons": "^1.0.0-rc3", "vue": "^3.2.47", - "vue-multiselect": "^2.1.9", + "vue-multiselect": "^3.4.0", + "vue-router": "^5.0.3", "vue3-toastify": "^0.2.8" }, "devDependencies": { - "@vitejs/plugin-vue": "^4.6.2", - "autoprefixer": "^10.4.21", - "postcss": "^8.5.6", - "tailwindcss": "^3.4.18", - "vite": "^4.5.14" + "@tailwindcss/vite": "^4.2.1", + "@vitejs/plugin-vue": "^6.0.4", + "tailwindcss": "^4.2.1", + "vite": "^7.3.1" } } diff --git a/bughog/web/vue/postcss.config.js b/bughog/web/vue/postcss.config.js index 2e7af2b7..2b4c7ed9 100644 --- a/bughog/web/vue/postcss.config.js +++ b/bughog/web/vue/postcss.config.js @@ -1,6 +1,3 @@ export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, + plugins: {}, } diff --git a/bughog/web/vue/src/App.vue b/bughog/web/vue/src/App.vue index 1d9e812e..b8e06903 100644 --- a/bughog/web/vue/src/App.vue +++ b/bughog/web/vue/src/App.vue @@ -1,773 +1,12 @@ - - - + diff --git a/bughog/web/vue/src/components/banner.vue b/bughog/web/vue/src/components/banner.vue new file mode 100644 index 00000000..11bb7bf5 --- /dev/null +++ b/bughog/web/vue/src/components/banner.vue @@ -0,0 +1,84 @@ + + + diff --git a/bughog/web/vue/src/components/evaluation-controls.vue b/bughog/web/vue/src/components/evaluation-controls.vue new file mode 100644 index 00000000..db1f112b --- /dev/null +++ b/bughog/web/vue/src/components/evaluation-controls.vue @@ -0,0 +1,48 @@ + + + diff --git a/bughog/web/vue/src/components/evaluation_status.vue b/bughog/web/vue/src/components/evaluation-status.vue similarity index 65% rename from bughog/web/vue/src/components/evaluation_status.vue rename to bughog/web/vue/src/components/evaluation-status.vue index fdd54c9f..ccbe69a0 100644 --- a/bughog/web/vue/src/components/evaluation_status.vue +++ b/bughog/web/vue/src/components/evaluation-status.vue @@ -20,17 +20,19 @@