├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── code-style.yml │ ├── codeql-analysis.yml │ ├── docker-image.yml │ ├── github-page.yml │ └── release.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── client ├── .gitignore ├── .npmrc ├── index.html ├── package-lock.json ├── package.json ├── public │ └── favicon.svg ├── src │ ├── App.svelte │ ├── LoadingScreen.svelte │ ├── ThemeSwitcher.svelte │ ├── api.mock.ts │ ├── api.ts │ ├── app.css │ ├── auth │ │ └── Login.svelte │ ├── containers │ │ ├── ContainerList.svelte │ │ ├── Dashboard.svelte │ │ ├── Filesystem.svelte │ │ ├── Index.svelte │ │ ├── LogViewer.svelte │ │ ├── Processes.svelte │ │ ├── SystemInfo.svelte │ │ ├── Terminal.svelte │ │ ├── types.d.ts │ │ └── utils.ts │ ├── dropdown │ │ ├── Dropdown.svelte │ │ ├── DropdownDivider.svelte │ │ └── DropdownItem.svelte │ ├── main.ts │ ├── messages │ │ ├── Message.svelte │ │ ├── MessageQueue.svelte │ │ ├── message-store.ts │ │ └── types.d.ts │ └── vite-env.d.ts ├── svelte.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── screenshot.png ├── server ├── auth.py ├── config.py ├── docker.py ├── logger.py ├── main.py ├── requirements.txt └── ws.py └── setup-dev ├── .env └── docker-compose.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | client/node_modules 2 | client/public/build 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/client" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "pip" # See documentation for possible values 13 | directory: "/" # Location of package manifests 14 | schedule: 15 | interval: "weekly" 16 | - package-ecosystem: 'github-actions' 17 | directory: '/' 18 | schedule: 19 | interval: 'weekly' -------------------------------------------------------------------------------- /.github/workflows/code-style.yml: -------------------------------------------------------------------------------- 1 | name: Code Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out the repo 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: '3.12' 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install flake8 flake8-bandit flake8-bugbear flake8-builtins flake8-comprehensions flake8-deprecated flake8-isort flake8-print flake8-quotes flake8-todo 24 | 25 | - name: Check linting 26 | run: | 27 | echo -e "[settings]\nline_length=179" > .isort.cfg 28 | python -m flake8 --max-line-length 179 . 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ main ] 9 | schedule: 10 | - cron: '45 2 * * 1' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'python' ] 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v3 33 | with: 34 | languages: ${{ matrix.language }} 35 | # If you wish to specify custom queries, you can do so here or in a config file. 36 | # By default, queries listed here will override any specified in a config file. 37 | # Prefix the list here with "+" to use these queries and those in the config file. 38 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 39 | 40 | - name: Perform CodeQL Analysis 41 | uses: github/codeql-action/analyze@v3 42 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v*.*.*' 9 | 10 | jobs: 11 | docker: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out the repo 15 | uses: actions/checkout@v4 16 | 17 | - name: Docker meta 18 | id: meta 19 | uses: docker/metadata-action@v5 20 | with: 21 | # list of Docker images to use as base name for tags 22 | images: | 23 | ${{ github.repository }} 24 | ghcr.io/${{ github.repository }} 25 | # generate Docker tags based on the following events/attributes 26 | tags: | 27 | type=semver,pattern={{major}}.{{minor}}.{{patch}} 28 | type=semver,pattern={{major}}.{{minor}} 29 | type=semver,pattern={{major}} 30 | type=edge,branch=main 31 | 32 | - name: Set up QEMU 33 | uses: docker/setup-qemu-action@v3 34 | 35 | - name: Set up Docker Buildx 36 | uses: docker/setup-buildx-action@v3 37 | 38 | - name: Log in to Docker Hub 39 | if: github.event_name != 'pull_request' 40 | uses: docker/login-action@v3 41 | with: 42 | username: ${{ secrets.DOCKER_USERNAME }} 43 | password: ${{ secrets.DOCKER_TOKEN }} 44 | 45 | - name: Login to GHCR 46 | if: github.event_name != 'pull_request' 47 | uses: docker/login-action@v3 48 | with: 49 | registry: ghcr.io 50 | username: ${{ github.repository_owner }} 51 | password: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | - name: Build and push 54 | uses: docker/build-push-action@v6 55 | with: 56 | context: . 57 | platforms: linux/amd64,linux/arm64 58 | push: ${{ github.event_name != 'pull_request' }} 59 | tags: ${{ steps.meta.outputs.tags }} 60 | labels: ${{ steps.meta.outputs.labels }} 61 | 62 | -------------------------------------------------------------------------------- /.github/workflows/github-page.yml: -------------------------------------------------------------------------------- 1 | name: Github Page Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | github-page: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: '22' 16 | cache: 'npm' 17 | cache-dependency-path: client/package-lock.json 18 | - name: apply demo customizations 19 | run: | 20 | sed -i "s/username: string = ''/username: string = 'admin'/" client/src/auth/Login.svelte 21 | sed -i "s/password: string = ''/password: string = '123456'/" client/src/auth/Login.svelte 22 | sed -i "s|// MOCK_PLACEHOLDER|import { WebSocketMock as WebSocket } from './api.mock'|" client/src/api.ts 23 | - run: cd client && npm install && npm run build 24 | - name: Deploy 25 | uses: peaceiris/actions-gh-pages@v4 26 | with: 27 | github_token: ${{ secrets.GITHUB_TOKEN }} 28 | publish_dir: ./client/dist 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Releases 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - id: imagetag 14 | run: | 15 | echo "DOCKER_IMAGE_TAG=$(echo ${{github.ref_name}} | cut -dv -f2)" >> $GITHUB_ENV 16 | - name: Create Release 17 | id: create_release 18 | if: github.event_name != 'pull_request' 19 | uses: actions/create-release@v1 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | with: 23 | tag_name: ${{ github.ref_name }} 24 | release_name: ${{ github.ref_name }} 25 | draft: false 26 | prerelease: false 27 | body: | 28 | Docker images: 29 | - ${{ github.repository }}:${{ env.DOCKER_IMAGE_TAG }} 30 | - ghcr.io/${{ github.repository }}:${{ env.DOCKER_IMAGE_TAG }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | client/dist/ 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.autopep8Args": [ 3 | "--max-line-length=179" 4 | ], 5 | "svelte.plugin.svelte.format.config.printWidth": 180, 6 | "javascript.format.semicolons": "remove", 7 | "typescript.format.semicolons": "remove", 8 | "svelte.enable-ts-plugin": true, 9 | "svelte.plugin.svelte.format.config.svelteStrictMode": true, 10 | "prettier.semi": false 11 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine AS client_builder 2 | 3 | WORKDIR /app 4 | 5 | COPY client/package.json . 6 | COPY client/package-lock.json . 7 | 8 | RUN npm install 9 | 10 | COPY client . 11 | 12 | RUN npm run check && \ 13 | npm run build 14 | 15 | 16 | FROM python:3.12.10-alpine 17 | 18 | ENV PYTHONUNBUFFERED=TRUE 19 | 20 | WORKDIR /app 21 | 22 | COPY server . 23 | 24 | RUN apk --no-cache update && \ 25 | pip install --no-cache-dir -r requirements.txt 26 | 27 | EXPOSE 8080/tcp 28 | 29 | CMD uvicorn main:app --host 0.0.0.0 --port 8080 --log-level info 30 | 31 | COPY --from=client_builder /app/dist /www 32 | 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 knrdl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CaaSa 2 | 3 | ## Container as a Service admin 4 | 5 | | [Demo](https://knrdl.github.io/caasa/) | [Docker Hub](https://hub.docker.com/r/knrdl/caasa) [![Docker Hub](https://img.shields.io/docker/pulls/knrdl/caasa.svg?logo=docker&style=popout-square)](https://hub.docker.com/r/knrdl/caasa) | [![CI](https://github.com/knrdl/caasa/actions/workflows/docker-image.yml/badge.svg)](https://github.com/knrdl/caasa/actions/workflows/docker-image.yml) 6 | |----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ----------- | 7 | 8 | Outsource the administration of a handful of containers to your co-workers. 9 | 10 | CaaSa provides a simple web-interface to handle basic container admin tasks: 11 | 12 | * View resource consumption/runtime behaviour 13 | * Restart, Stop containers 14 | * View logs and process tree 15 | * Execute terminal commands 16 | * Browse filesystem, upload/download files 17 | 18 | Restrict permissions per container and user 19 | 20 | ## Getting started 21 | 22 | ### 1. Deploy CaaSa 23 | 24 | ```yaml 25 | version: '2.4' 26 | services: 27 | caasa: 28 | image: knrdl/caasa # or: ghcr.io/knrdl/caasa 29 | restart: always 30 | environment: 31 | ROLES_caasa_admin_basic: info, state, logs, procs, files, files-read 32 | ROLES_caasa_admin_full: info, info-annotations, state, logs, term, procs, files, files-read, files-write 33 | AUTH_API_URL: https://identity.mycompany.com/login 34 | AUTH_API_FIELD_USERNAME: username 35 | AUTH_API_FIELD_PASSWORD: password 36 | ports: 37 | - "8080:8080" 38 | volumes: 39 | - /var/run/docker.sock:/var/run/docker.sock # for Docker 40 | # - /run/user/1000/podman/podman.sock:/var/run/docker.sock # for Podman 41 | mem_limit: 150m 42 | cpu_count: 1 43 | ``` 44 | 45 | > :warning: **For production** is a reverse-proxy with TLS termination in front of CaaSa highly recommended 46 | 47 | Roles are defined via environment variables and might contain these permissions: 48 | 49 | * **info**: display basic container metadata 50 | * **info-annotations**: display environment variables and container labels (may contain secrets) 51 | * **state**: allow start, stop, restart container 52 | * **logs**: display container terminal output 53 | * **term**: spawn (root privileged) terminal inside container 54 | * **procs**: display running processes 55 | * **files**: list files and directories in container 56 | * **files-read**: user can download files from container 57 | * **files-write**: user can upload files to container 58 | 59 | ### 2. Authentication 60 | 61 | There are 3 methods available: 62 | 63 | #### 2.1 Dummy authentication 64 | 65 | Set the environment variable `AUTH_API_URL=https://wikipedia.org` (or any other server which responds with *200 OK* to a *HTTP POST* request). 66 | 67 | Now you can log in with **any** username and password combination. 68 | 69 | > :warning: Only useful for tests and demos. Not suitable for productive usage. 70 | 71 | #### 2.2 WebForm authentication 72 | 73 | To perform logins CaaSa sends *HTTP POST* requests to the URL defined in the environment variable `AUTH_API_URL`. The requests contain a json body with username and password. The json field names are defined via environment variables `AUTH_API_FIELD_USERNAME` (default: *username*) and `AUTH_API_FIELD_PASSWORD` (default: *password*). A 2XX response code (e.g. *200 OK*) represents a successful login. 74 | 75 | #### 2.3 WebProxy authentication 76 | 77 | CaaSa can read the username from a HTTP request header. This header must be supplied by a reverse proxy in front of CaaSa. It can be specified via the environment variable `WEBPROXY_AUTH_HEADER`. A typical header name is *Remote-User*. 78 | 79 | > :warning: The header must be supplied by the reverse proxy. A value provided by a malicious client must be overwritten. 80 | 81 | ### 3. Annotate containers 82 | 83 | If a container should be visible in CaaSa, it must be annotated with a label defined above as `ROLES_` and list all permitted usernames (or user IDs). Usernames are treated as case-insensitive. 84 | 85 | ```bash 86 | docker run -it --rm --name caasa_demo --label caasa.admin.full=user1,user2 nginx:alpine 87 | ``` 88 | 89 | In this example the users `user1` and `user2` are granted the rights of the `caasa.admin.full` role for the container `caasa_demo` via CaaSa web interface. 90 | 91 | ## Screenshot 92 | 93 | ![Screenshot](screenshot.png) 94 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | public/build/ 3 | 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /client/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | CaaSa 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /client/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "caasa", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "caasa", 9 | "version": "1.0.0", 10 | "devDependencies": { 11 | "@fortawesome/free-brands-svg-icons": "^6.7.2", 12 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 13 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 14 | "@tsconfig/svelte": "^5.0.4", 15 | "@xterm/addon-fit": "^0.10.0", 16 | "@xterm/xterm": "^5.5.0", 17 | "bootstrap-dark-5": "1.1.3", 18 | "strip-ansi": "^7.1.0", 19 | "svelte": "^5.33.13", 20 | "svelte-check": "^4.2.1", 21 | "svelte-fa": "^4.0.4", 22 | "tslib": "^2.8.1", 23 | "typescript": "^5.8.3", 24 | "vite": "^6.3.5" 25 | } 26 | }, 27 | "node_modules/@ampproject/remapping": { 28 | "version": "2.3.0", 29 | "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", 30 | "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", 31 | "dev": true, 32 | "license": "Apache-2.0", 33 | "dependencies": { 34 | "@jridgewell/gen-mapping": "^0.3.5", 35 | "@jridgewell/trace-mapping": "^0.3.24" 36 | }, 37 | "engines": { 38 | "node": ">=6.0.0" 39 | } 40 | }, 41 | "node_modules/@esbuild/aix-ppc64": { 42 | "version": "0.25.3", 43 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz", 44 | "integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==", 45 | "cpu": [ 46 | "ppc64" 47 | ], 48 | "dev": true, 49 | "license": "MIT", 50 | "optional": true, 51 | "os": [ 52 | "aix" 53 | ], 54 | "engines": { 55 | "node": ">=18" 56 | } 57 | }, 58 | "node_modules/@esbuild/android-arm": { 59 | "version": "0.25.3", 60 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz", 61 | "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==", 62 | "cpu": [ 63 | "arm" 64 | ], 65 | "dev": true, 66 | "license": "MIT", 67 | "optional": true, 68 | "os": [ 69 | "android" 70 | ], 71 | "engines": { 72 | "node": ">=18" 73 | } 74 | }, 75 | "node_modules/@esbuild/android-arm64": { 76 | "version": "0.25.3", 77 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz", 78 | "integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==", 79 | "cpu": [ 80 | "arm64" 81 | ], 82 | "dev": true, 83 | "license": "MIT", 84 | "optional": true, 85 | "os": [ 86 | "android" 87 | ], 88 | "engines": { 89 | "node": ">=18" 90 | } 91 | }, 92 | "node_modules/@esbuild/android-x64": { 93 | "version": "0.25.3", 94 | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz", 95 | "integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==", 96 | "cpu": [ 97 | "x64" 98 | ], 99 | "dev": true, 100 | "license": "MIT", 101 | "optional": true, 102 | "os": [ 103 | "android" 104 | ], 105 | "engines": { 106 | "node": ">=18" 107 | } 108 | }, 109 | "node_modules/@esbuild/darwin-arm64": { 110 | "version": "0.25.3", 111 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz", 112 | "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==", 113 | "cpu": [ 114 | "arm64" 115 | ], 116 | "dev": true, 117 | "license": "MIT", 118 | "optional": true, 119 | "os": [ 120 | "darwin" 121 | ], 122 | "engines": { 123 | "node": ">=18" 124 | } 125 | }, 126 | "node_modules/@esbuild/darwin-x64": { 127 | "version": "0.25.3", 128 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz", 129 | "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==", 130 | "cpu": [ 131 | "x64" 132 | ], 133 | "dev": true, 134 | "license": "MIT", 135 | "optional": true, 136 | "os": [ 137 | "darwin" 138 | ], 139 | "engines": { 140 | "node": ">=18" 141 | } 142 | }, 143 | "node_modules/@esbuild/freebsd-arm64": { 144 | "version": "0.25.3", 145 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz", 146 | "integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==", 147 | "cpu": [ 148 | "arm64" 149 | ], 150 | "dev": true, 151 | "license": "MIT", 152 | "optional": true, 153 | "os": [ 154 | "freebsd" 155 | ], 156 | "engines": { 157 | "node": ">=18" 158 | } 159 | }, 160 | "node_modules/@esbuild/freebsd-x64": { 161 | "version": "0.25.3", 162 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz", 163 | "integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==", 164 | "cpu": [ 165 | "x64" 166 | ], 167 | "dev": true, 168 | "license": "MIT", 169 | "optional": true, 170 | "os": [ 171 | "freebsd" 172 | ], 173 | "engines": { 174 | "node": ">=18" 175 | } 176 | }, 177 | "node_modules/@esbuild/linux-arm": { 178 | "version": "0.25.3", 179 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz", 180 | "integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==", 181 | "cpu": [ 182 | "arm" 183 | ], 184 | "dev": true, 185 | "license": "MIT", 186 | "optional": true, 187 | "os": [ 188 | "linux" 189 | ], 190 | "engines": { 191 | "node": ">=18" 192 | } 193 | }, 194 | "node_modules/@esbuild/linux-arm64": { 195 | "version": "0.25.3", 196 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz", 197 | "integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==", 198 | "cpu": [ 199 | "arm64" 200 | ], 201 | "dev": true, 202 | "license": "MIT", 203 | "optional": true, 204 | "os": [ 205 | "linux" 206 | ], 207 | "engines": { 208 | "node": ">=18" 209 | } 210 | }, 211 | "node_modules/@esbuild/linux-ia32": { 212 | "version": "0.25.3", 213 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz", 214 | "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==", 215 | "cpu": [ 216 | "ia32" 217 | ], 218 | "dev": true, 219 | "license": "MIT", 220 | "optional": true, 221 | "os": [ 222 | "linux" 223 | ], 224 | "engines": { 225 | "node": ">=18" 226 | } 227 | }, 228 | "node_modules/@esbuild/linux-loong64": { 229 | "version": "0.25.3", 230 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz", 231 | "integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==", 232 | "cpu": [ 233 | "loong64" 234 | ], 235 | "dev": true, 236 | "license": "MIT", 237 | "optional": true, 238 | "os": [ 239 | "linux" 240 | ], 241 | "engines": { 242 | "node": ">=18" 243 | } 244 | }, 245 | "node_modules/@esbuild/linux-mips64el": { 246 | "version": "0.25.3", 247 | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz", 248 | "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==", 249 | "cpu": [ 250 | "mips64el" 251 | ], 252 | "dev": true, 253 | "license": "MIT", 254 | "optional": true, 255 | "os": [ 256 | "linux" 257 | ], 258 | "engines": { 259 | "node": ">=18" 260 | } 261 | }, 262 | "node_modules/@esbuild/linux-ppc64": { 263 | "version": "0.25.3", 264 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz", 265 | "integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==", 266 | "cpu": [ 267 | "ppc64" 268 | ], 269 | "dev": true, 270 | "license": "MIT", 271 | "optional": true, 272 | "os": [ 273 | "linux" 274 | ], 275 | "engines": { 276 | "node": ">=18" 277 | } 278 | }, 279 | "node_modules/@esbuild/linux-riscv64": { 280 | "version": "0.25.3", 281 | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz", 282 | "integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==", 283 | "cpu": [ 284 | "riscv64" 285 | ], 286 | "dev": true, 287 | "license": "MIT", 288 | "optional": true, 289 | "os": [ 290 | "linux" 291 | ], 292 | "engines": { 293 | "node": ">=18" 294 | } 295 | }, 296 | "node_modules/@esbuild/linux-s390x": { 297 | "version": "0.25.3", 298 | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz", 299 | "integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==", 300 | "cpu": [ 301 | "s390x" 302 | ], 303 | "dev": true, 304 | "license": "MIT", 305 | "optional": true, 306 | "os": [ 307 | "linux" 308 | ], 309 | "engines": { 310 | "node": ">=18" 311 | } 312 | }, 313 | "node_modules/@esbuild/linux-x64": { 314 | "version": "0.25.3", 315 | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz", 316 | "integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==", 317 | "cpu": [ 318 | "x64" 319 | ], 320 | "dev": true, 321 | "license": "MIT", 322 | "optional": true, 323 | "os": [ 324 | "linux" 325 | ], 326 | "engines": { 327 | "node": ">=18" 328 | } 329 | }, 330 | "node_modules/@esbuild/netbsd-arm64": { 331 | "version": "0.25.3", 332 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz", 333 | "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==", 334 | "cpu": [ 335 | "arm64" 336 | ], 337 | "dev": true, 338 | "license": "MIT", 339 | "optional": true, 340 | "os": [ 341 | "netbsd" 342 | ], 343 | "engines": { 344 | "node": ">=18" 345 | } 346 | }, 347 | "node_modules/@esbuild/netbsd-x64": { 348 | "version": "0.25.3", 349 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz", 350 | "integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==", 351 | "cpu": [ 352 | "x64" 353 | ], 354 | "dev": true, 355 | "license": "MIT", 356 | "optional": true, 357 | "os": [ 358 | "netbsd" 359 | ], 360 | "engines": { 361 | "node": ">=18" 362 | } 363 | }, 364 | "node_modules/@esbuild/openbsd-arm64": { 365 | "version": "0.25.3", 366 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz", 367 | "integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==", 368 | "cpu": [ 369 | "arm64" 370 | ], 371 | "dev": true, 372 | "license": "MIT", 373 | "optional": true, 374 | "os": [ 375 | "openbsd" 376 | ], 377 | "engines": { 378 | "node": ">=18" 379 | } 380 | }, 381 | "node_modules/@esbuild/openbsd-x64": { 382 | "version": "0.25.3", 383 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz", 384 | "integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==", 385 | "cpu": [ 386 | "x64" 387 | ], 388 | "dev": true, 389 | "license": "MIT", 390 | "optional": true, 391 | "os": [ 392 | "openbsd" 393 | ], 394 | "engines": { 395 | "node": ">=18" 396 | } 397 | }, 398 | "node_modules/@esbuild/sunos-x64": { 399 | "version": "0.25.3", 400 | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", 401 | "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==", 402 | "cpu": [ 403 | "x64" 404 | ], 405 | "dev": true, 406 | "license": "MIT", 407 | "optional": true, 408 | "os": [ 409 | "sunos" 410 | ], 411 | "engines": { 412 | "node": ">=18" 413 | } 414 | }, 415 | "node_modules/@esbuild/win32-arm64": { 416 | "version": "0.25.3", 417 | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz", 418 | "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==", 419 | "cpu": [ 420 | "arm64" 421 | ], 422 | "dev": true, 423 | "license": "MIT", 424 | "optional": true, 425 | "os": [ 426 | "win32" 427 | ], 428 | "engines": { 429 | "node": ">=18" 430 | } 431 | }, 432 | "node_modules/@esbuild/win32-ia32": { 433 | "version": "0.25.3", 434 | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz", 435 | "integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==", 436 | "cpu": [ 437 | "ia32" 438 | ], 439 | "dev": true, 440 | "license": "MIT", 441 | "optional": true, 442 | "os": [ 443 | "win32" 444 | ], 445 | "engines": { 446 | "node": ">=18" 447 | } 448 | }, 449 | "node_modules/@esbuild/win32-x64": { 450 | "version": "0.25.3", 451 | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz", 452 | "integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==", 453 | "cpu": [ 454 | "x64" 455 | ], 456 | "dev": true, 457 | "license": "MIT", 458 | "optional": true, 459 | "os": [ 460 | "win32" 461 | ], 462 | "engines": { 463 | "node": ">=18" 464 | } 465 | }, 466 | "node_modules/@fortawesome/fontawesome-common-types": { 467 | "version": "6.7.2", 468 | "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", 469 | "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", 470 | "dev": true, 471 | "license": "MIT", 472 | "engines": { 473 | "node": ">=6" 474 | } 475 | }, 476 | "node_modules/@fortawesome/free-brands-svg-icons": { 477 | "version": "6.7.2", 478 | "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.2.tgz", 479 | "integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==", 480 | "dev": true, 481 | "license": "(CC-BY-4.0 AND MIT)", 482 | "dependencies": { 483 | "@fortawesome/fontawesome-common-types": "6.7.2" 484 | }, 485 | "engines": { 486 | "node": ">=6" 487 | } 488 | }, 489 | "node_modules/@fortawesome/free-solid-svg-icons": { 490 | "version": "6.7.2", 491 | "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", 492 | "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", 493 | "dev": true, 494 | "license": "(CC-BY-4.0 AND MIT)", 495 | "dependencies": { 496 | "@fortawesome/fontawesome-common-types": "6.7.2" 497 | }, 498 | "engines": { 499 | "node": ">=6" 500 | } 501 | }, 502 | "node_modules/@jridgewell/gen-mapping": { 503 | "version": "0.3.8", 504 | "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", 505 | "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", 506 | "dev": true, 507 | "license": "MIT", 508 | "dependencies": { 509 | "@jridgewell/set-array": "^1.2.1", 510 | "@jridgewell/sourcemap-codec": "^1.4.10", 511 | "@jridgewell/trace-mapping": "^0.3.24" 512 | }, 513 | "engines": { 514 | "node": ">=6.0.0" 515 | } 516 | }, 517 | "node_modules/@jridgewell/resolve-uri": { 518 | "version": "3.1.2", 519 | "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 520 | "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 521 | "dev": true, 522 | "license": "MIT", 523 | "engines": { 524 | "node": ">=6.0.0" 525 | } 526 | }, 527 | "node_modules/@jridgewell/set-array": { 528 | "version": "1.2.1", 529 | "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", 530 | "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", 531 | "dev": true, 532 | "license": "MIT", 533 | "engines": { 534 | "node": ">=6.0.0" 535 | } 536 | }, 537 | "node_modules/@jridgewell/sourcemap-codec": { 538 | "version": "1.5.0", 539 | "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", 540 | "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", 541 | "dev": true, 542 | "license": "MIT" 543 | }, 544 | "node_modules/@jridgewell/trace-mapping": { 545 | "version": "0.3.25", 546 | "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", 547 | "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", 548 | "dev": true, 549 | "license": "MIT", 550 | "dependencies": { 551 | "@jridgewell/resolve-uri": "^3.1.0", 552 | "@jridgewell/sourcemap-codec": "^1.4.14" 553 | } 554 | }, 555 | "node_modules/@popperjs/core": { 556 | "version": "2.11.8", 557 | "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", 558 | "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", 559 | "dev": true, 560 | "license": "MIT", 561 | "peer": true, 562 | "funding": { 563 | "type": "opencollective", 564 | "url": "https://opencollective.com/popperjs" 565 | } 566 | }, 567 | "node_modules/@rollup/rollup-android-arm-eabi": { 568 | "version": "4.40.1", 569 | "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz", 570 | "integrity": "sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==", 571 | "cpu": [ 572 | "arm" 573 | ], 574 | "dev": true, 575 | "license": "MIT", 576 | "optional": true, 577 | "os": [ 578 | "android" 579 | ] 580 | }, 581 | "node_modules/@rollup/rollup-android-arm64": { 582 | "version": "4.40.1", 583 | "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz", 584 | "integrity": "sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==", 585 | "cpu": [ 586 | "arm64" 587 | ], 588 | "dev": true, 589 | "license": "MIT", 590 | "optional": true, 591 | "os": [ 592 | "android" 593 | ] 594 | }, 595 | "node_modules/@rollup/rollup-darwin-arm64": { 596 | "version": "4.40.1", 597 | "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz", 598 | "integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==", 599 | "cpu": [ 600 | "arm64" 601 | ], 602 | "dev": true, 603 | "license": "MIT", 604 | "optional": true, 605 | "os": [ 606 | "darwin" 607 | ] 608 | }, 609 | "node_modules/@rollup/rollup-darwin-x64": { 610 | "version": "4.40.1", 611 | "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.1.tgz", 612 | "integrity": "sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==", 613 | "cpu": [ 614 | "x64" 615 | ], 616 | "dev": true, 617 | "license": "MIT", 618 | "optional": true, 619 | "os": [ 620 | "darwin" 621 | ] 622 | }, 623 | "node_modules/@rollup/rollup-freebsd-arm64": { 624 | "version": "4.40.1", 625 | "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz", 626 | "integrity": "sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==", 627 | "cpu": [ 628 | "arm64" 629 | ], 630 | "dev": true, 631 | "license": "MIT", 632 | "optional": true, 633 | "os": [ 634 | "freebsd" 635 | ] 636 | }, 637 | "node_modules/@rollup/rollup-freebsd-x64": { 638 | "version": "4.40.1", 639 | "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.1.tgz", 640 | "integrity": "sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==", 641 | "cpu": [ 642 | "x64" 643 | ], 644 | "dev": true, 645 | "license": "MIT", 646 | "optional": true, 647 | "os": [ 648 | "freebsd" 649 | ] 650 | }, 651 | "node_modules/@rollup/rollup-linux-arm-gnueabihf": { 652 | "version": "4.40.1", 653 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.1.tgz", 654 | "integrity": "sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==", 655 | "cpu": [ 656 | "arm" 657 | ], 658 | "dev": true, 659 | "license": "MIT", 660 | "optional": true, 661 | "os": [ 662 | "linux" 663 | ] 664 | }, 665 | "node_modules/@rollup/rollup-linux-arm-musleabihf": { 666 | "version": "4.40.1", 667 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.1.tgz", 668 | "integrity": "sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==", 669 | "cpu": [ 670 | "arm" 671 | ], 672 | "dev": true, 673 | "license": "MIT", 674 | "optional": true, 675 | "os": [ 676 | "linux" 677 | ] 678 | }, 679 | "node_modules/@rollup/rollup-linux-arm64-gnu": { 680 | "version": "4.40.1", 681 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.1.tgz", 682 | "integrity": "sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==", 683 | "cpu": [ 684 | "arm64" 685 | ], 686 | "dev": true, 687 | "license": "MIT", 688 | "optional": true, 689 | "os": [ 690 | "linux" 691 | ] 692 | }, 693 | "node_modules/@rollup/rollup-linux-arm64-musl": { 694 | "version": "4.40.1", 695 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.1.tgz", 696 | "integrity": "sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==", 697 | "cpu": [ 698 | "arm64" 699 | ], 700 | "dev": true, 701 | "license": "MIT", 702 | "optional": true, 703 | "os": [ 704 | "linux" 705 | ] 706 | }, 707 | "node_modules/@rollup/rollup-linux-loongarch64-gnu": { 708 | "version": "4.40.1", 709 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.1.tgz", 710 | "integrity": "sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==", 711 | "cpu": [ 712 | "loong64" 713 | ], 714 | "dev": true, 715 | "license": "MIT", 716 | "optional": true, 717 | "os": [ 718 | "linux" 719 | ] 720 | }, 721 | "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { 722 | "version": "4.40.1", 723 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.1.tgz", 724 | "integrity": "sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==", 725 | "cpu": [ 726 | "ppc64" 727 | ], 728 | "dev": true, 729 | "license": "MIT", 730 | "optional": true, 731 | "os": [ 732 | "linux" 733 | ] 734 | }, 735 | "node_modules/@rollup/rollup-linux-riscv64-gnu": { 736 | "version": "4.40.1", 737 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.1.tgz", 738 | "integrity": "sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==", 739 | "cpu": [ 740 | "riscv64" 741 | ], 742 | "dev": true, 743 | "license": "MIT", 744 | "optional": true, 745 | "os": [ 746 | "linux" 747 | ] 748 | }, 749 | "node_modules/@rollup/rollup-linux-riscv64-musl": { 750 | "version": "4.40.1", 751 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.1.tgz", 752 | "integrity": "sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==", 753 | "cpu": [ 754 | "riscv64" 755 | ], 756 | "dev": true, 757 | "license": "MIT", 758 | "optional": true, 759 | "os": [ 760 | "linux" 761 | ] 762 | }, 763 | "node_modules/@rollup/rollup-linux-s390x-gnu": { 764 | "version": "4.40.1", 765 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.1.tgz", 766 | "integrity": "sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==", 767 | "cpu": [ 768 | "s390x" 769 | ], 770 | "dev": true, 771 | "license": "MIT", 772 | "optional": true, 773 | "os": [ 774 | "linux" 775 | ] 776 | }, 777 | "node_modules/@rollup/rollup-linux-x64-gnu": { 778 | "version": "4.40.1", 779 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.1.tgz", 780 | "integrity": "sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==", 781 | "cpu": [ 782 | "x64" 783 | ], 784 | "dev": true, 785 | "license": "MIT", 786 | "optional": true, 787 | "os": [ 788 | "linux" 789 | ] 790 | }, 791 | "node_modules/@rollup/rollup-linux-x64-musl": { 792 | "version": "4.40.1", 793 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.1.tgz", 794 | "integrity": "sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==", 795 | "cpu": [ 796 | "x64" 797 | ], 798 | "dev": true, 799 | "license": "MIT", 800 | "optional": true, 801 | "os": [ 802 | "linux" 803 | ] 804 | }, 805 | "node_modules/@rollup/rollup-win32-arm64-msvc": { 806 | "version": "4.40.1", 807 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.1.tgz", 808 | "integrity": "sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==", 809 | "cpu": [ 810 | "arm64" 811 | ], 812 | "dev": true, 813 | "license": "MIT", 814 | "optional": true, 815 | "os": [ 816 | "win32" 817 | ] 818 | }, 819 | "node_modules/@rollup/rollup-win32-ia32-msvc": { 820 | "version": "4.40.1", 821 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.1.tgz", 822 | "integrity": "sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==", 823 | "cpu": [ 824 | "ia32" 825 | ], 826 | "dev": true, 827 | "license": "MIT", 828 | "optional": true, 829 | "os": [ 830 | "win32" 831 | ] 832 | }, 833 | "node_modules/@rollup/rollup-win32-x64-msvc": { 834 | "version": "4.40.1", 835 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.1.tgz", 836 | "integrity": "sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==", 837 | "cpu": [ 838 | "x64" 839 | ], 840 | "dev": true, 841 | "license": "MIT", 842 | "optional": true, 843 | "os": [ 844 | "win32" 845 | ] 846 | }, 847 | "node_modules/@sveltejs/acorn-typescript": { 848 | "version": "1.0.5", 849 | "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", 850 | "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", 851 | "dev": true, 852 | "license": "MIT", 853 | "peerDependencies": { 854 | "acorn": "^8.9.0" 855 | } 856 | }, 857 | "node_modules/@sveltejs/vite-plugin-svelte": { 858 | "version": "5.0.3", 859 | "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.0.3.tgz", 860 | "integrity": "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw==", 861 | "dev": true, 862 | "license": "MIT", 863 | "dependencies": { 864 | "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", 865 | "debug": "^4.4.0", 866 | "deepmerge": "^4.3.1", 867 | "kleur": "^4.1.5", 868 | "magic-string": "^0.30.15", 869 | "vitefu": "^1.0.4" 870 | }, 871 | "engines": { 872 | "node": "^18.0.0 || ^20.0.0 || >=22" 873 | }, 874 | "peerDependencies": { 875 | "svelte": "^5.0.0", 876 | "vite": "^6.0.0" 877 | } 878 | }, 879 | "node_modules/@sveltejs/vite-plugin-svelte-inspector": { 880 | "version": "4.0.1", 881 | "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", 882 | "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", 883 | "dev": true, 884 | "license": "MIT", 885 | "dependencies": { 886 | "debug": "^4.3.7" 887 | }, 888 | "engines": { 889 | "node": "^18.0.0 || ^20.0.0 || >=22" 890 | }, 891 | "peerDependencies": { 892 | "@sveltejs/vite-plugin-svelte": "^5.0.0", 893 | "svelte": "^5.0.0", 894 | "vite": "^6.0.0" 895 | } 896 | }, 897 | "node_modules/@tsconfig/svelte": { 898 | "version": "5.0.4", 899 | "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.4.tgz", 900 | "integrity": "sha512-BV9NplVgLmSi4mwKzD8BD/NQ8erOY/nUE/GpgWe2ckx+wIQF5RyRirn/QsSSCPeulVpc3RA/iJt6DpfTIZps0Q==", 901 | "dev": true, 902 | "license": "MIT" 903 | }, 904 | "node_modules/@types/estree": { 905 | "version": "1.0.7", 906 | "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", 907 | "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", 908 | "dev": true, 909 | "license": "MIT" 910 | }, 911 | "node_modules/@xterm/addon-fit": { 912 | "version": "0.10.0", 913 | "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", 914 | "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", 915 | "dev": true, 916 | "license": "MIT", 917 | "peerDependencies": { 918 | "@xterm/xterm": "^5.0.0" 919 | } 920 | }, 921 | "node_modules/@xterm/xterm": { 922 | "version": "5.5.0", 923 | "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", 924 | "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", 925 | "dev": true, 926 | "license": "MIT" 927 | }, 928 | "node_modules/acorn": { 929 | "version": "8.14.1", 930 | "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", 931 | "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", 932 | "dev": true, 933 | "license": "MIT", 934 | "bin": { 935 | "acorn": "bin/acorn" 936 | }, 937 | "engines": { 938 | "node": ">=0.4.0" 939 | } 940 | }, 941 | "node_modules/ansi-regex": { 942 | "version": "6.1.0", 943 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", 944 | "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", 945 | "dev": true, 946 | "license": "MIT", 947 | "engines": { 948 | "node": ">=12" 949 | }, 950 | "funding": { 951 | "url": "https://github.com/chalk/ansi-regex?sponsor=1" 952 | } 953 | }, 954 | "node_modules/aria-query": { 955 | "version": "5.3.2", 956 | "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", 957 | "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", 958 | "dev": true, 959 | "license": "Apache-2.0", 960 | "engines": { 961 | "node": ">= 0.4" 962 | } 963 | }, 964 | "node_modules/axobject-query": { 965 | "version": "4.1.0", 966 | "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", 967 | "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", 968 | "dev": true, 969 | "license": "Apache-2.0", 970 | "engines": { 971 | "node": ">= 0.4" 972 | } 973 | }, 974 | "node_modules/bootstrap": { 975 | "version": "5.3.5", 976 | "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.5.tgz", 977 | "integrity": "sha512-ct1CHKtiobRimyGzmsSldEtM03E8fcEX4Tb3dGXz1V8faRwM50+vfHwTzOxB3IlKO7m+9vTH3s/3C6T2EAPeTA==", 978 | "dev": true, 979 | "funding": [ 980 | { 981 | "type": "github", 982 | "url": "https://github.com/sponsors/twbs" 983 | }, 984 | { 985 | "type": "opencollective", 986 | "url": "https://opencollective.com/bootstrap" 987 | } 988 | ], 989 | "license": "MIT", 990 | "peerDependencies": { 991 | "@popperjs/core": "^2.11.8" 992 | } 993 | }, 994 | "node_modules/bootstrap-dark-5": { 995 | "version": "1.1.3", 996 | "resolved": "https://registry.npmjs.org/bootstrap-dark-5/-/bootstrap-dark-5-1.1.3.tgz", 997 | "integrity": "sha512-3Paopsp8wyOM1oeaLWLFuUZThhRc3tBYKUnoF+uwrU/xN4G47MCLZlALBJNqYqAecg7dSln9vgaYK1CwPnTeBw==", 998 | "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", 999 | "dev": true, 1000 | "license": "MIT", 1001 | "dependencies": { 1002 | "bootstrap": "^5.1.3" 1003 | }, 1004 | "peerDependencies": { 1005 | "@popperjs/core": "^2.10.2" 1006 | } 1007 | }, 1008 | "node_modules/chokidar": { 1009 | "version": "4.0.3", 1010 | "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", 1011 | "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", 1012 | "dev": true, 1013 | "license": "MIT", 1014 | "dependencies": { 1015 | "readdirp": "^4.0.1" 1016 | }, 1017 | "engines": { 1018 | "node": ">= 14.16.0" 1019 | }, 1020 | "funding": { 1021 | "url": "https://paulmillr.com/funding/" 1022 | } 1023 | }, 1024 | "node_modules/clsx": { 1025 | "version": "2.1.1", 1026 | "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", 1027 | "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", 1028 | "dev": true, 1029 | "license": "MIT", 1030 | "engines": { 1031 | "node": ">=6" 1032 | } 1033 | }, 1034 | "node_modules/debug": { 1035 | "version": "4.4.0", 1036 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", 1037 | "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", 1038 | "dev": true, 1039 | "license": "MIT", 1040 | "dependencies": { 1041 | "ms": "^2.1.3" 1042 | }, 1043 | "engines": { 1044 | "node": ">=6.0" 1045 | }, 1046 | "peerDependenciesMeta": { 1047 | "supports-color": { 1048 | "optional": true 1049 | } 1050 | } 1051 | }, 1052 | "node_modules/deepmerge": { 1053 | "version": "4.3.1", 1054 | "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", 1055 | "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", 1056 | "dev": true, 1057 | "license": "MIT", 1058 | "engines": { 1059 | "node": ">=0.10.0" 1060 | } 1061 | }, 1062 | "node_modules/esbuild": { 1063 | "version": "0.25.3", 1064 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", 1065 | "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==", 1066 | "dev": true, 1067 | "hasInstallScript": true, 1068 | "license": "MIT", 1069 | "bin": { 1070 | "esbuild": "bin/esbuild" 1071 | }, 1072 | "engines": { 1073 | "node": ">=18" 1074 | }, 1075 | "optionalDependencies": { 1076 | "@esbuild/aix-ppc64": "0.25.3", 1077 | "@esbuild/android-arm": "0.25.3", 1078 | "@esbuild/android-arm64": "0.25.3", 1079 | "@esbuild/android-x64": "0.25.3", 1080 | "@esbuild/darwin-arm64": "0.25.3", 1081 | "@esbuild/darwin-x64": "0.25.3", 1082 | "@esbuild/freebsd-arm64": "0.25.3", 1083 | "@esbuild/freebsd-x64": "0.25.3", 1084 | "@esbuild/linux-arm": "0.25.3", 1085 | "@esbuild/linux-arm64": "0.25.3", 1086 | "@esbuild/linux-ia32": "0.25.3", 1087 | "@esbuild/linux-loong64": "0.25.3", 1088 | "@esbuild/linux-mips64el": "0.25.3", 1089 | "@esbuild/linux-ppc64": "0.25.3", 1090 | "@esbuild/linux-riscv64": "0.25.3", 1091 | "@esbuild/linux-s390x": "0.25.3", 1092 | "@esbuild/linux-x64": "0.25.3", 1093 | "@esbuild/netbsd-arm64": "0.25.3", 1094 | "@esbuild/netbsd-x64": "0.25.3", 1095 | "@esbuild/openbsd-arm64": "0.25.3", 1096 | "@esbuild/openbsd-x64": "0.25.3", 1097 | "@esbuild/sunos-x64": "0.25.3", 1098 | "@esbuild/win32-arm64": "0.25.3", 1099 | "@esbuild/win32-ia32": "0.25.3", 1100 | "@esbuild/win32-x64": "0.25.3" 1101 | } 1102 | }, 1103 | "node_modules/esm-env": { 1104 | "version": "1.2.2", 1105 | "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", 1106 | "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", 1107 | "dev": true, 1108 | "license": "MIT" 1109 | }, 1110 | "node_modules/esrap": { 1111 | "version": "1.4.6", 1112 | "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.6.tgz", 1113 | "integrity": "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==", 1114 | "dev": true, 1115 | "license": "MIT", 1116 | "dependencies": { 1117 | "@jridgewell/sourcemap-codec": "^1.4.15" 1118 | } 1119 | }, 1120 | "node_modules/fdir": { 1121 | "version": "6.4.4", 1122 | "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", 1123 | "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", 1124 | "dev": true, 1125 | "license": "MIT", 1126 | "peerDependencies": { 1127 | "picomatch": "^3 || ^4" 1128 | }, 1129 | "peerDependenciesMeta": { 1130 | "picomatch": { 1131 | "optional": true 1132 | } 1133 | } 1134 | }, 1135 | "node_modules/fsevents": { 1136 | "version": "2.3.3", 1137 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 1138 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 1139 | "dev": true, 1140 | "hasInstallScript": true, 1141 | "license": "MIT", 1142 | "optional": true, 1143 | "os": [ 1144 | "darwin" 1145 | ], 1146 | "engines": { 1147 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1148 | } 1149 | }, 1150 | "node_modules/is-reference": { 1151 | "version": "3.0.3", 1152 | "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", 1153 | "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", 1154 | "dev": true, 1155 | "license": "MIT", 1156 | "dependencies": { 1157 | "@types/estree": "^1.0.6" 1158 | } 1159 | }, 1160 | "node_modules/kleur": { 1161 | "version": "4.1.5", 1162 | "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", 1163 | "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", 1164 | "dev": true, 1165 | "license": "MIT", 1166 | "engines": { 1167 | "node": ">=6" 1168 | } 1169 | }, 1170 | "node_modules/locate-character": { 1171 | "version": "3.0.0", 1172 | "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", 1173 | "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", 1174 | "dev": true, 1175 | "license": "MIT" 1176 | }, 1177 | "node_modules/magic-string": { 1178 | "version": "0.30.17", 1179 | "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", 1180 | "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", 1181 | "dev": true, 1182 | "license": "MIT", 1183 | "dependencies": { 1184 | "@jridgewell/sourcemap-codec": "^1.5.0" 1185 | } 1186 | }, 1187 | "node_modules/mri": { 1188 | "version": "1.2.0", 1189 | "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", 1190 | "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", 1191 | "dev": true, 1192 | "license": "MIT", 1193 | "engines": { 1194 | "node": ">=4" 1195 | } 1196 | }, 1197 | "node_modules/ms": { 1198 | "version": "2.1.3", 1199 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 1200 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 1201 | "dev": true, 1202 | "license": "MIT" 1203 | }, 1204 | "node_modules/nanoid": { 1205 | "version": "3.3.11", 1206 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", 1207 | "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1208 | "dev": true, 1209 | "funding": [ 1210 | { 1211 | "type": "github", 1212 | "url": "https://github.com/sponsors/ai" 1213 | } 1214 | ], 1215 | "license": "MIT", 1216 | "bin": { 1217 | "nanoid": "bin/nanoid.cjs" 1218 | }, 1219 | "engines": { 1220 | "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1221 | } 1222 | }, 1223 | "node_modules/picocolors": { 1224 | "version": "1.1.1", 1225 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 1226 | "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 1227 | "dev": true, 1228 | "license": "ISC" 1229 | }, 1230 | "node_modules/picomatch": { 1231 | "version": "4.0.2", 1232 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", 1233 | "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", 1234 | "dev": true, 1235 | "license": "MIT", 1236 | "engines": { 1237 | "node": ">=12" 1238 | }, 1239 | "funding": { 1240 | "url": "https://github.com/sponsors/jonschlinkert" 1241 | } 1242 | }, 1243 | "node_modules/postcss": { 1244 | "version": "8.5.3", 1245 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", 1246 | "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", 1247 | "dev": true, 1248 | "funding": [ 1249 | { 1250 | "type": "opencollective", 1251 | "url": "https://opencollective.com/postcss/" 1252 | }, 1253 | { 1254 | "type": "tidelift", 1255 | "url": "https://tidelift.com/funding/github/npm/postcss" 1256 | }, 1257 | { 1258 | "type": "github", 1259 | "url": "https://github.com/sponsors/ai" 1260 | } 1261 | ], 1262 | "license": "MIT", 1263 | "dependencies": { 1264 | "nanoid": "^3.3.8", 1265 | "picocolors": "^1.1.1", 1266 | "source-map-js": "^1.2.1" 1267 | }, 1268 | "engines": { 1269 | "node": "^10 || ^12 || >=14" 1270 | } 1271 | }, 1272 | "node_modules/readdirp": { 1273 | "version": "4.1.2", 1274 | "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", 1275 | "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", 1276 | "dev": true, 1277 | "license": "MIT", 1278 | "engines": { 1279 | "node": ">= 14.18.0" 1280 | }, 1281 | "funding": { 1282 | "type": "individual", 1283 | "url": "https://paulmillr.com/funding/" 1284 | } 1285 | }, 1286 | "node_modules/rollup": { 1287 | "version": "4.40.1", 1288 | "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.1.tgz", 1289 | "integrity": "sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==", 1290 | "dev": true, 1291 | "license": "MIT", 1292 | "dependencies": { 1293 | "@types/estree": "1.0.7" 1294 | }, 1295 | "bin": { 1296 | "rollup": "dist/bin/rollup" 1297 | }, 1298 | "engines": { 1299 | "node": ">=18.0.0", 1300 | "npm": ">=8.0.0" 1301 | }, 1302 | "optionalDependencies": { 1303 | "@rollup/rollup-android-arm-eabi": "4.40.1", 1304 | "@rollup/rollup-android-arm64": "4.40.1", 1305 | "@rollup/rollup-darwin-arm64": "4.40.1", 1306 | "@rollup/rollup-darwin-x64": "4.40.1", 1307 | "@rollup/rollup-freebsd-arm64": "4.40.1", 1308 | "@rollup/rollup-freebsd-x64": "4.40.1", 1309 | "@rollup/rollup-linux-arm-gnueabihf": "4.40.1", 1310 | "@rollup/rollup-linux-arm-musleabihf": "4.40.1", 1311 | "@rollup/rollup-linux-arm64-gnu": "4.40.1", 1312 | "@rollup/rollup-linux-arm64-musl": "4.40.1", 1313 | "@rollup/rollup-linux-loongarch64-gnu": "4.40.1", 1314 | "@rollup/rollup-linux-powerpc64le-gnu": "4.40.1", 1315 | "@rollup/rollup-linux-riscv64-gnu": "4.40.1", 1316 | "@rollup/rollup-linux-riscv64-musl": "4.40.1", 1317 | "@rollup/rollup-linux-s390x-gnu": "4.40.1", 1318 | "@rollup/rollup-linux-x64-gnu": "4.40.1", 1319 | "@rollup/rollup-linux-x64-musl": "4.40.1", 1320 | "@rollup/rollup-win32-arm64-msvc": "4.40.1", 1321 | "@rollup/rollup-win32-ia32-msvc": "4.40.1", 1322 | "@rollup/rollup-win32-x64-msvc": "4.40.1", 1323 | "fsevents": "~2.3.2" 1324 | } 1325 | }, 1326 | "node_modules/sade": { 1327 | "version": "1.8.1", 1328 | "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", 1329 | "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", 1330 | "dev": true, 1331 | "license": "MIT", 1332 | "dependencies": { 1333 | "mri": "^1.1.0" 1334 | }, 1335 | "engines": { 1336 | "node": ">=6" 1337 | } 1338 | }, 1339 | "node_modules/source-map-js": { 1340 | "version": "1.2.1", 1341 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", 1342 | "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", 1343 | "dev": true, 1344 | "license": "BSD-3-Clause", 1345 | "engines": { 1346 | "node": ">=0.10.0" 1347 | } 1348 | }, 1349 | "node_modules/strip-ansi": { 1350 | "version": "7.1.0", 1351 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", 1352 | "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", 1353 | "dev": true, 1354 | "license": "MIT", 1355 | "dependencies": { 1356 | "ansi-regex": "^6.0.1" 1357 | }, 1358 | "engines": { 1359 | "node": ">=12" 1360 | }, 1361 | "funding": { 1362 | "url": "https://github.com/chalk/strip-ansi?sponsor=1" 1363 | } 1364 | }, 1365 | "node_modules/svelte": { 1366 | "version": "5.33.13", 1367 | "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.33.13.tgz", 1368 | "integrity": "sha512-uT3BAPpHGaJqpOgdwJwIK7P4JkBkSS0vylbaRXxQjt1gr+DZ9BiPkhmbZw3ql8LJofUyz5XyrzzQDgQQdfP86Q==", 1369 | "dev": true, 1370 | "license": "MIT", 1371 | "dependencies": { 1372 | "@ampproject/remapping": "^2.3.0", 1373 | "@jridgewell/sourcemap-codec": "^1.5.0", 1374 | "@sveltejs/acorn-typescript": "^1.0.5", 1375 | "@types/estree": "^1.0.5", 1376 | "acorn": "^8.12.1", 1377 | "aria-query": "^5.3.1", 1378 | "axobject-query": "^4.1.0", 1379 | "clsx": "^2.1.1", 1380 | "esm-env": "^1.2.1", 1381 | "esrap": "^1.4.6", 1382 | "is-reference": "^3.0.3", 1383 | "locate-character": "^3.0.0", 1384 | "magic-string": "^0.30.11", 1385 | "zimmerframe": "^1.1.2" 1386 | }, 1387 | "engines": { 1388 | "node": ">=18" 1389 | } 1390 | }, 1391 | "node_modules/svelte-check": { 1392 | "version": "4.2.1", 1393 | "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.2.1.tgz", 1394 | "integrity": "sha512-e49SU1RStvQhoipkQ/aonDhHnG3qxHSBtNfBRb9pxVXoa+N7qybAo32KgA9wEb2PCYFNaDg7bZCdhLD1vHpdYA==", 1395 | "dev": true, 1396 | "license": "MIT", 1397 | "dependencies": { 1398 | "@jridgewell/trace-mapping": "^0.3.25", 1399 | "chokidar": "^4.0.1", 1400 | "fdir": "^6.2.0", 1401 | "picocolors": "^1.0.0", 1402 | "sade": "^1.7.4" 1403 | }, 1404 | "bin": { 1405 | "svelte-check": "bin/svelte-check" 1406 | }, 1407 | "engines": { 1408 | "node": ">= 18.0.0" 1409 | }, 1410 | "peerDependencies": { 1411 | "svelte": "^4.0.0 || ^5.0.0-next.0", 1412 | "typescript": ">=5.0.0" 1413 | } 1414 | }, 1415 | "node_modules/svelte-fa": { 1416 | "version": "4.0.4", 1417 | "resolved": "https://registry.npmjs.org/svelte-fa/-/svelte-fa-4.0.4.tgz", 1418 | "integrity": "sha512-85BomCGkTrH8kPDGvb8JrVwq9CqR9foprbKjxemP4Dtg3zPR7OXj5hD0xVYK0C+UCzFI1zooLoK/ndIX6aYXAw==", 1419 | "dev": true, 1420 | "license": "MIT", 1421 | "peerDependencies": { 1422 | "svelte": "^4.0.0 || ^5.0.0" 1423 | } 1424 | }, 1425 | "node_modules/tinyglobby": { 1426 | "version": "0.2.13", 1427 | "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", 1428 | "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", 1429 | "dev": true, 1430 | "license": "MIT", 1431 | "dependencies": { 1432 | "fdir": "^6.4.4", 1433 | "picomatch": "^4.0.2" 1434 | }, 1435 | "engines": { 1436 | "node": ">=12.0.0" 1437 | }, 1438 | "funding": { 1439 | "url": "https://github.com/sponsors/SuperchupuDev" 1440 | } 1441 | }, 1442 | "node_modules/tslib": { 1443 | "version": "2.8.1", 1444 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 1445 | "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 1446 | "dev": true, 1447 | "license": "0BSD" 1448 | }, 1449 | "node_modules/typescript": { 1450 | "version": "5.8.3", 1451 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", 1452 | "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", 1453 | "dev": true, 1454 | "license": "Apache-2.0", 1455 | "bin": { 1456 | "tsc": "bin/tsc", 1457 | "tsserver": "bin/tsserver" 1458 | }, 1459 | "engines": { 1460 | "node": ">=14.17" 1461 | } 1462 | }, 1463 | "node_modules/vite": { 1464 | "version": "6.3.5", 1465 | "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", 1466 | "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", 1467 | "dev": true, 1468 | "license": "MIT", 1469 | "dependencies": { 1470 | "esbuild": "^0.25.0", 1471 | "fdir": "^6.4.4", 1472 | "picomatch": "^4.0.2", 1473 | "postcss": "^8.5.3", 1474 | "rollup": "^4.34.9", 1475 | "tinyglobby": "^0.2.13" 1476 | }, 1477 | "bin": { 1478 | "vite": "bin/vite.js" 1479 | }, 1480 | "engines": { 1481 | "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 1482 | }, 1483 | "funding": { 1484 | "url": "https://github.com/vitejs/vite?sponsor=1" 1485 | }, 1486 | "optionalDependencies": { 1487 | "fsevents": "~2.3.3" 1488 | }, 1489 | "peerDependencies": { 1490 | "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", 1491 | "jiti": ">=1.21.0", 1492 | "less": "*", 1493 | "lightningcss": "^1.21.0", 1494 | "sass": "*", 1495 | "sass-embedded": "*", 1496 | "stylus": "*", 1497 | "sugarss": "*", 1498 | "terser": "^5.16.0", 1499 | "tsx": "^4.8.1", 1500 | "yaml": "^2.4.2" 1501 | }, 1502 | "peerDependenciesMeta": { 1503 | "@types/node": { 1504 | "optional": true 1505 | }, 1506 | "jiti": { 1507 | "optional": true 1508 | }, 1509 | "less": { 1510 | "optional": true 1511 | }, 1512 | "lightningcss": { 1513 | "optional": true 1514 | }, 1515 | "sass": { 1516 | "optional": true 1517 | }, 1518 | "sass-embedded": { 1519 | "optional": true 1520 | }, 1521 | "stylus": { 1522 | "optional": true 1523 | }, 1524 | "sugarss": { 1525 | "optional": true 1526 | }, 1527 | "terser": { 1528 | "optional": true 1529 | }, 1530 | "tsx": { 1531 | "optional": true 1532 | }, 1533 | "yaml": { 1534 | "optional": true 1535 | } 1536 | } 1537 | }, 1538 | "node_modules/vitefu": { 1539 | "version": "1.0.6", 1540 | "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.6.tgz", 1541 | "integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==", 1542 | "dev": true, 1543 | "license": "MIT", 1544 | "workspaces": [ 1545 | "tests/deps/*", 1546 | "tests/projects/*" 1547 | ], 1548 | "peerDependencies": { 1549 | "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" 1550 | }, 1551 | "peerDependenciesMeta": { 1552 | "vite": { 1553 | "optional": true 1554 | } 1555 | } 1556 | }, 1557 | "node_modules/zimmerframe": { 1558 | "version": "1.1.2", 1559 | "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", 1560 | "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", 1561 | "dev": true, 1562 | "license": "MIT" 1563 | } 1564 | } 1565 | } 1566 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "caasa", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-check --tsconfig ./tsconfig.json" 11 | }, 12 | "devDependencies": { 13 | "@fortawesome/free-brands-svg-icons": "^6.7.2", 14 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 15 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 16 | "@tsconfig/svelte": "^5.0.4", 17 | "bootstrap-dark-5": "1.1.3", 18 | "strip-ansi": "^7.1.0", 19 | "svelte": "^5.33.13", 20 | "svelte-check": "^4.2.1", 21 | "svelte-fa": "^4.0.4", 22 | "tslib": "^2.8.1", 23 | "typescript": "^5.8.3", 24 | "vite": "^6.3.5", 25 | "@xterm/xterm": "^5.5.0", 26 | "@xterm/addon-fit": "^0.10.0" 27 | } 28 | } -------------------------------------------------------------------------------- /client/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /client/src/App.svelte: -------------------------------------------------------------------------------- 1 | 49 | 50 | 51 | 52 | 53 | 54 |
55 |
56 | {#if loggedInUsername} 57 | 58 | 59 | 60 | {/if} 61 | 62 | 63 | 64 | 65 | Container as a Service admin 66 | 67 |
68 | 69 | {#if loggedInUsername && userCanLogout} 70 | 73 | {/if} 74 |
75 |
76 | {#if loggedInUsername} 77 | 78 | {:else} 79 | 80 | {/if} 81 |
82 | 83 | 113 | -------------------------------------------------------------------------------- /client/src/LoadingScreen.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | {#if visible} 14 |
15 |
16 |
17 | {#each [1, 2, 3] as n} 18 |
19 | Loading... 20 |
21 | {/each} 22 |
23 | {#if text} 24 |
{text}
25 | {/if} 26 |
27 |
28 | {/if} 29 | 30 | 40 | -------------------------------------------------------------------------------- /client/src/ThemeSwitcher.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 34 | 35 | 37 | -------------------------------------------------------------------------------- /client/src/api.mock.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | function mockResponse(body) { 4 | switch (body.request) { 5 | case 'login': 6 | return `{"response": "login", "payload": {"username": "${body.payload.username}"}}` 7 | case 'get_system_info': 8 | return `{"response": "get_system_info", "payload": {"engine_version": "Docker 20.10.7", "containers": {"total": 178, "running": 170, "stopped": 0}, "os": "Ubuntu 20.04.3 LTS", "cpus": 24, "mem": 31277850624}}` 9 | case 'get_container_list': 10 | return `{"response": "get_container_list", "payload": [ 11 | {"id": "2b7eb6b9d2f7fd2dd26738500a81a16310f78e46eea2f9c2a27ed876d85d8586", "name": "CaaSa Demo", "namespace": null, "status": "running", "permissions": ["info", "info-annotations", "procs", "files-read", "files-write", "files", "logs", "state", "term"]}, 12 | {"id": "2b7eb6b9d2f7fd2dd26738500a81a16310f78e46eea2f9c2a27ed876d85d8587", "name": "Backend", "namespace": "Cool Web App", "status": "running", "permissions": ["info", "info-annotations", "procs", "files-read", "files-write", "files", "logs", "state", "term"]}, 13 | {"id": "2b7eb6b9d2f7fd2dd26738500a81a16310f78e46eea2f9c2a27ed876d85d8588", "name": "Gateway", "namespace": "Cool Web App", "status": "running", "permissions": ["info", "info-annotations", "procs", "files-read", "files-write", "files", "logs", "state", "term"]} 14 | ]}` 15 | case 'get_processes': 16 | return `{"response": "get_processes", "payload": [{"pid": "45756", "ppid": "45733", "%cpu": "0.0", "%mem": "0.0", "user": "root", "stime": "16:24", "command": "nginx: master process nginx -g daemon off;", "level": 0}, {"pid": "45822", "ppid": "45756", "%cpu": "0.0", "%mem": "0.0", "user": "systemd+", "stime": "16:24", "command": "nginx: worker process", "level": 1}, {"pid": "45823", "ppid": "45756", "%cpu": "0.0", "%mem": "0.0", "user": "systemd+", "stime": "16:24", "command": "nginx: worker process", "level": 1}, {"pid": "45824", "ppid": "45756", "%cpu": "0.0", "%mem": "0.0", "user": "systemd+", "stime": "16:24", "command": "nginx: worker process", "level": 1}, {"pid": "45825", "ppid": "45756", "%cpu": "0.0", "%mem": "0.0", "user": "systemd+", "stime": "16:24", "command": "nginx: worker process", "level": 1}, {"pid": "45826", "ppid": "45756", "%cpu": "0.0", "%mem": "0.0", "user": "systemd+", "stime": "16:24", "command": "nginx: worker process", "level": 1}, {"pid": "45827", "ppid": "45756", "%cpu": "0.0", "%mem": "0.0", "user": "systemd+", "stime": "16:24", "command": "nginx: worker process", "level": 1}, {"pid": "45828", "ppid": "45756", "%cpu": "0.0", "%mem": "0.0", "user": "systemd+", "stime": "16:24", "command": "nginx: worker process", "level": 1}, {"pid": "45829", "ppid": "45756", "%cpu": "0.0", "%mem": "0.0", "user": "systemd+", "stime": "16:24", "command": "nginx: worker process", "level": 1}, {"pid": "45830", "ppid": "45756", "%cpu": "0.0", "%mem": "0.0", "user": "systemd+", "stime": "16:24", "command": "nginx: worker process", "level": 1}, {"pid": "45831", "ppid": "45756", "%cpu": "0.0", "%mem": "0.0", "user": "systemd+", "stime": "16:24", "command": "nginx: worker process", "level": 1}, {"pid": "45832", "ppid": "45756", "%cpu": "0.0", "%mem": "0.0", "user": "systemd+", "stime": "16:24", "command": "nginx: worker process", "level": 1}, {"pid": "45833", "ppid": "45756", "%cpu": "0.0", "%mem": "0.0", "user": "systemd+", "stime": "16:24", "command": "nginx: worker process", "level": 1}, {"pid": "46825", "ppid": "45733", "%cpu": "0.0", "%mem": "0.0", "user": "root", "stime": "16:34", "command": "sh", "level": 0}]}` 17 | case 'get_container_info': 18 | const container_name = { 19 | '2b7eb6b9d2f7fd2dd26738500a81a16310f78e46eea2f9c2a27ed876d85d8586': '/caasa_demo', 20 | '2b7eb6b9d2f7fd2dd26738500a81a16310f78e46eea2f9c2a27ed876d85d8587': '/cool_web_app_backend_1', 21 | '2b7eb6b9d2f7fd2dd26738500a81a16310f78e46eea2f9c2a27ed876d85d8588': '/cool_web_app_gateway_1', 22 | } 23 | return `{"response": "get_container_info", "payload": {"id": "${body.payload.container_id}", "name": "${container_name[body.payload.container_id]}", "status": "running", "command": "/docker-entrypoint.sh nginx -g daemon off;", "created_at": "2021-12-02T10:33:16.628618832Z", "started_at": "2021-12-02T10:33:17.015866729Z", "finished_at": "0001-01-01T00:00:00Z", "crashes": 0, "env": {"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "NGINX_VERSION": "1.21.1", "NJS_VERSION": "0.6.1", "PKG_RELEASE": "1"}, "labels": {"caasa.admin.full": "user1,user2", "maintainer": "NGINX Docker Maintainers "}, "image": {"name": "nginx:alpine", "hash": "sha256:b9e2356ea1be9452f3777a587b0b6a30bc16c295fe6190eda6a0776522f27439"}, "mem": {"used": 10452992, "max_used": 19345408, "total": 52428800}, "cpu": {"perc": ${Math.random() * 10}}, "net": {"rx_bytes": 18201, "tx_bytes": 0}, "ports": ["80/tcp"]}}` 24 | case 'get_container_logs': 25 | if (body.payload.onlynew) 26 | return `{"response": "get_container_logs", "payload":[]}` 27 | else 28 | return `{"response": "get_container_logs", "payload": ["2021-12-02T10:33:17.021946340Z /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration","2021-12-02T10:33:17.021988550Z /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/","2021-12-02T10:33:17.023417432Z /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh","2021-12-02T10:33:17.031231257Z 10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf","2021-12-02T10:33:17.055742664Z 10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf","2021-12-02T10:33:17.056041305Z /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh","2021-12-02T10:33:17.059979717Z /docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh","2021-12-02T10:33:17.062059371Z /docker-entrypoint.sh: Configuration complete; ready for start up","2021-12-02T10:33:17.074027144Z 2021/12/02 10:33:17 [notice] 1#1: using the \\"epoll\\" event method","2021-12-02T10:33:17.074056600Z 2021/12/02 10:33:17 [notice] 1#1: nginx/1.21.1","2021-12-02T10:33:17.074063653Z 2021/12/02 10:33:17 [notice] 1#1: built by gcc 10.3.1 20210424 (Alpine 10.3.1_git20210424) ","2021-12-02T10:33:17.074069845Z 2021/12/02 10:33:17 [notice] 1#1: OS: Linux 5.11.0-40-generic","2021-12-02T10:33:17.074075475Z 2021/12/02 10:33:17 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576","2021-12-02T10:33:17.074245905Z 2021/12/02 10:33:17 [notice] 1#1: start worker processes","2021-12-02T10:33:17.074276953Z 2021/12/02 10:33:17 [notice] 1#1: start worker process 32","2021-12-02T10:33:17.074379486Z 2021/12/02 10:33:17 [notice] 1#1: start worker process 33","2021-12-02T10:33:17.074401427Z 2021/12/02 10:33:17 [notice] 1#1: start worker process 34","2021-12-02T10:33:17.074513127Z 2021/12/02 10:33:17 [notice] 1#1: start worker process 35","2021-12-02T10:33:17.074601242Z 2021/12/02 10:33:17 [notice] 1#1: start worker process 36","2021-12-02T10:33:17.074666745Z 2021/12/02 10:33:17 [notice] 1#1: start worker process 37","2021-12-02T10:33:17.074817979Z 2021/12/02 10:33:17 [notice] 1#1: start worker process 38","2021-12-02T10:33:17.074873423Z 2021/12/02 10:33:17 [notice] 1#1: start worker process 39","2021-12-02T10:33:17.075007344Z 2021/12/02 10:33:17 [notice] 1#1: start worker process 40","2021-12-02T10:33:17.075141746Z 2021/12/02 10:33:17 [notice] 1#1: start worker process 41","2021-12-02T10:33:17.075279715Z 2021/12/02 10:33:17 [notice] 1#1: start worker process 42","2021-12-02T10:33:17.075393118Z 2021/12/02 10:33:17 [notice] 1#1: start worker process 43"]}` 29 | case 'get_filesystem_info': 30 | return `{"response": "get_filesystem_info", "payload": {"workdir": "", "mounts": [{"type": "bind", "destination": "/etc/ssl/certs", "readonly": true, "source": "/etc/ssl/certs"}]}}` 31 | case 'get_directory_list': 32 | if (body.payload.path === '' || body.payload.path === '/') 33 | return `{"response": "get_directory_list", "payload": {"entries": [{"type": "f", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "0B", "modtime": "2021-12-02 16:19:13", "name": ".dockerenv"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-06-15 14:34:40", "name": "bin"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "360B", "modtime": "2021-12-02 16:19:13", "name": "dev"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-07-06 19:40:27", "name": "docker-entrypoint.d"}, {"type": "f", "permissions": "rwxrwxr-x", "owner": "root", "group": "root", "filesize": "1.2KB", "modtime": "2021-07-06 19:40:16", "name": "docker-entrypoint.sh"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-12-02 16:19:13", "name": "etc"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-06-15 14:34:40", "name": "home"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-06-15 14:34:40", "name": "lib"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-06-15 14:34:40", "name": "media"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-06-15 14:34:40", "name": "mnt"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-06-15 14:34:40", "name": "opt"}, {"type": "d", "permissions": "r-xr-xr-x", "owner": "root", "group": "root", "filesize": "0B", "modtime": "2021-12-02 16:19:13", "name": "proc"}, {"type": "d", "permissions": "rwx------", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-06-15 14:34:40", "name": "root"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-12-02 16:19:13", "name": "run"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-06-15 14:34:40", "name": "sbin"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-06-15 14:34:40", "name": "srv"}, {"type": "d", "permissions": "r-xr-xr-x", "owner": "root", "group": "root", "filesize": "0B", "modtime": "2021-12-02 16:19:13", "name": "sys"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-06-15 14:34:40", "name": "usr"}, {"type": "d", "permissions": "rwxr-xr-x", "owner": "root", "group": "root", "filesize": "4.0KB", "modtime": "2021-06-15 14:34:40", "name": "var"}], "path": "/"}}` 34 | else 35 | return false 36 | case 'spawn_terminal': 37 | return `{"response": "spawn_terminal", "payload": {"execId": "2680be7b4aa3c0b6599edf3521f5813a770ccec7966a59c57cfdac5b4e3b7e9a"}}` 38 | case 'resize_terminal': 39 | return true 40 | case 'transmit_terminal_input': 41 | if (body.payload.data === "\r") 42 | return `56!{"response": "receive_terminal_output", "payload": null}\r\nThis is just a demo` 43 | else 44 | return `56!{"response": "receive_terminal_output", "payload": null}` + body.payload.data 45 | default: 46 | return false 47 | } 48 | } 49 | 50 | export class WebSocketMock implements WebSocket { 51 | constructor(url: string) { 52 | } 53 | binaryType: BinaryType 54 | bufferedAmount: number 55 | extensions: string 56 | onclose: (this: WebSocket, ev: CloseEvent) => any 57 | onerror: (this: WebSocket, ev: Event) => any 58 | onmessage: (this: WebSocket, ev: MessageEvent) => any 59 | onopen: (this: WebSocket, ev: Event) => any 60 | protocol: string 61 | readyState: number 62 | url: string 63 | close(code?: number, reason?: string): void { 64 | } 65 | CLOSED: number 66 | CLOSING: number 67 | CONNECTING: number 68 | OPEN: number 69 | addEventListener(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void 70 | addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void 71 | addEventListener(type: any, listener: any, options?: any): void { 72 | throw new Error("Method not implemented.") 73 | } 74 | removeEventListener(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, options?: boolean | EventListenerOptions): void 75 | removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void 76 | removeEventListener(type: any, listener: any, options?: any): void { 77 | throw new Error("Method not implemented.") 78 | } 79 | dispatchEvent(event: Event): boolean { 80 | throw new Error("Method not implemented.") 81 | } 82 | 83 | send(requestData: string) { 84 | // console.log('WS>', requestData) 85 | const body = JSON.parse(requestData) 86 | const res = mockResponse(body) 87 | // console.log('WS<', res) 88 | const msg: Event & { data: string | Blob } = { 89 | data: undefined, 90 | ...new Event('api-mock') 91 | } 92 | if (res === false) { 93 | msg.data = `{"response": "${body.request}", "error": "Sorry, command not mocked in demo"}` 94 | } else if (res === true) { 95 | // do not send response 96 | } else if (res[0] !== '{') { 97 | // console.log('send as blob', res) 98 | msg.data = new Blob([res]) 99 | } else { 100 | msg.data = res 101 | } 102 | if (msg.data) { 103 | setTimeout(() => this.onmessage(msg as MessageEvent), 200) 104 | if (typeof msg.data === 'string' && msg.data.includes('spawn_terminal')) { 105 | console.log('extra response for terminal') 106 | const msg2: Event & { data: Blob } = { 107 | data: undefined, 108 | ...new Event('api-mock') 109 | } 110 | msg2.data = new Blob([`56!{"response": "receive_terminal_output", "payload": null}/ # `]) 111 | setTimeout(() => this.onmessage(msg2 as MessageEvent), 300) 112 | } 113 | } 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /client/src/api.ts: -------------------------------------------------------------------------------- 1 | // MOCK_PLACEHOLDER 2 | // see .github/workflows/github-page.yml 3 | 4 | let ws: WebSocket 5 | 6 | type ApiRequest = 7 | 'login' 8 | | 'webproxy_auth' 9 | | 'get_system_info' 10 | | 'get_container_list' 11 | | 'get_container_info' 12 | | 'get_container_logs' 13 | | 'set_container_state' 14 | | 'get_processes' 15 | | 'spawn_terminal' 16 | | 'close_terminal' 17 | | 'receive_terminal_output' 18 | | 'transmit_terminal_input' 19 | | 'resize_terminal' 20 | | 'get_filesystem_info' 21 | | 'get_directory_list' 22 | | 'download_file' 23 | | 'create_folder' 24 | | 'upload_file' 25 | type ApiResponse = ApiRequest | 'ws-error' | 'ws-close' 26 | 27 | let callbacksSuccess: { [key: string]: (data: any) => void } = {} 28 | let callbacksFailure: { [key: string]: (data: any) => void } = {} 29 | 30 | function propagateSuccessResponse(event: ApiResponse, payload: any) { 31 | if (event in callbacksSuccess) { 32 | callbacksSuccess[event](payload) 33 | } else { 34 | console.error('WS-API Unknown websocket event:', event) 35 | } 36 | } 37 | 38 | function propagateErrorResponse(event: ApiResponse, error: string) { 39 | if (event in callbacksFailure) { 40 | callbacksFailure[event](error) 41 | } else { 42 | console.error('WS-API Unknown websocket event:', event) 43 | } 44 | } 45 | 46 | function unregisterAll() { 47 | callbacksSuccess = {} 48 | callbacksFailure = {} 49 | } 50 | 51 | 52 | export default { 53 | /** 54 | * Init a websocket connection to the api, any old connection will be destroyed 55 | */ 56 | init() { 57 | try { 58 | if (ws) { 59 | ws.close(1000) 60 | } 61 | ws = new WebSocket(`${window.location.protocol.replace('http', 'ws')}//${window.location.host}/ws`) 62 | ws.binaryType = 'blob' 63 | ws.onclose = (ev: CloseEvent) => { 64 | propagateErrorResponse('ws-close', ev.reason || 'Connection closed') 65 | unregisterAll() 66 | } 67 | ws.onmessage = async (ev: MessageEvent) => { 68 | if (ev.data instanceof Blob) { 69 | const headerSize = parseInt(await ev.data.slice(0, 10).text()) 70 | const headerIndicatorSize = headerSize.toString().length + 1 71 | const header = await ev.data.slice(headerIndicatorSize, headerIndicatorSize + headerSize).text() 72 | const d = JSON.parse(header) 73 | if (d.error) 74 | propagateErrorResponse(d.response, d.error) 75 | else 76 | propagateSuccessResponse(d.response, { 77 | payload: d.payload, 78 | blob: ev.data.slice(headerIndicatorSize + headerSize) 79 | }) 80 | } else { 81 | const d = JSON.parse(ev.data) 82 | if (d.error) 83 | propagateErrorResponse(d.response, d.error) 84 | else 85 | propagateSuccessResponse(d.response, d.payload) 86 | } 87 | } 88 | ws.onerror = (ev: Event) => { 89 | propagateErrorResponse('ws-error', 'Connection error') 90 | } 91 | } catch (e) { 92 | alert(e) 93 | } 94 | }, 95 | 96 | /** 97 | * Register a callback to listen to events sent by websockets api 98 | * @param event name of the event 99 | * @param success Callback function 100 | * @param failure Callback function 101 | */ 102 | register(event: ApiResponse, success: ((data: T) => void) | null = null, failure: ((err: string) => void) | null = null) { 103 | if (success) 104 | callbacksSuccess[event] = success 105 | if (failure) 106 | callbacksFailure[event] = failure 107 | }, 108 | 109 | /** 110 | * Unregister a callback to stop listening to events sent by websockets api 111 | * @param events names of the events 112 | */ 113 | unregister(...events: ApiResponse[]) { 114 | events.forEach(event => { 115 | delete callbacksFailure[event] 116 | delete callbacksSuccess[event] 117 | }) 118 | }, 119 | 120 | /** 121 | * Send a action to the websockets api 122 | * @param request name of the action 123 | * @param payload any json serializable object 124 | */ 125 | send(request: ApiRequest, payload?: any) { 126 | ws.send(JSON.stringify({ request, payload })) 127 | }, 128 | 129 | close() { 130 | unregisterAll() 131 | ws.close() 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /client/src/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | overscroll-behavior: none; 3 | } 4 | 5 | .tabs .card-header { 6 | padding: 0; 7 | list-style: none; 8 | display: flex; 9 | border: none !important; 10 | overflow-x: auto; 11 | } 12 | 13 | .tabs .card-header li { 14 | flex-grow: 1; 15 | } 16 | 17 | .tabs .card-header li button { 18 | border: none; 19 | color: #0d6efd; 20 | padding: 1rem; 21 | width: 100%; 22 | min-height: 100%; 23 | } 24 | 25 | .tabs .card-header li button.active { 26 | background: #fff; 27 | color: #495057 28 | } 29 | 30 | html.dark .tabs .card-header li button { 31 | background-color: rgba(250, 250, 250, .03); 32 | } 33 | 34 | html.dark .tabs .card-header li button.active { 35 | background-color: #222; 36 | color: #92a0ae; 37 | } 38 | 39 | .tabs .card-body { 40 | padding: 2rem 1rem; 41 | } 42 | 43 | .tabs .card-body>.row { 44 | margin-top: .5em; 45 | } 46 | 47 | .tabs .card-body>.row .col button { 48 | padding: .25em; 49 | } -------------------------------------------------------------------------------- /client/src/auth/Login.svelte: -------------------------------------------------------------------------------- 1 | 40 | 41 | {#if loading} 42 | 43 | {/if} 44 |
45 | {#if errMsg} 46 | {errMsg} 47 | {/if} 48 | 72 |
73 | 74 | 79 | -------------------------------------------------------------------------------- /client/src/containers/ContainerList.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 51 | 52 | 69 | -------------------------------------------------------------------------------- /client/src/containers/Dashboard.svelte: -------------------------------------------------------------------------------- 1 | 80 | 81 |
82 | {#if loading} 83 |
84 | Loading... 85 | 86 |
87 | {:else} 88 |
89 |
90 | 91 |
92 |
93 |
94 |
95 |
96 | Container 97 | #{container.id.substring(0, 12)} 98 |
99 |
100 |
{container.name.replace(/^\//, '')}
101 |
102 |
103 | {#if enable_actions} 104 |
105 | {#if container.status === 'running'} 106 | {#if !runningAction || runningAction === 'restart'} 107 | 115 | {/if} 116 | {#if !runningAction || runningAction === 'stop'} 117 | 125 | {/if} 126 | {:else if !runningAction || runningAction === 'start'} 127 | 135 | {/if} 136 |
137 | {/if} 138 |
139 |
140 | 141 | {container.command} 142 |
143 |
144 |
145 | 146 |
147 |
148 | 149 |
150 |
151 |
152 | Image 153 | {#if container.image?.hash !== container?.image?.name} 154 | {container.image.hash.substring(0, 12 + 7)} 155 | {/if} 156 |
157 |
158 |
159 | {#if !image_name.includes('/')} 160 | {image_name} 161 | {:else if image_name.match(/^\w+\/\w+$/)} 162 | {image_name} 163 | {:else} 164 | {image_name} 165 | {/if} 166 | 167 | 168 | {image_tag} 169 | 170 |
171 |
172 |
173 |
174 | 175 | {#if container.cpu} 176 |
177 |
178 | 179 |
180 |
181 |
Processor
182 |
183 |
184 | {round(container.cpu.perc)}% 185 |
186 |
187 |
188 |
189 | {/if} 190 | 191 | {#if container.mem} 192 |
193 |
194 | 195 |
196 |
197 |
198 | Memory 199 | {#if container.mem.used !== null} 200 | Now {bytes2human(container.mem.used)} 201 | {/if} 202 | {#if container.mem.max_used !== null} 203 | Max {bytes2human(container.mem.max_used)} 204 | {/if} 205 | {#if container.mem.total !== null} 206 | Limit {bytes2human(container.mem.total)} 207 | {/if} 208 |
209 | {#if container.mem.used !== null && container.mem.total !== null} 210 |
211 |
212 | {round(mem_perc)}% 213 |
214 | {#if container.mem.max_used !== null} 215 |
216 | {/if} 217 |
218 | {/if} 219 |
220 |
221 | {/if} 222 | 223 | {#if container.net || container.ports?.length > 0} 224 |
225 |
226 | 227 |
228 | {#if container.net} 229 |
230 |
Network
231 | ▼ {bytes2human(container.net.rx_bytes)} 232 | ▲ {bytes2human(container.net.tx_bytes)} 233 |
234 | {/if} 235 | {#if container.ports?.length > 0} 236 |
237 |
Ports
238 | {#each container.ports as port} 239 | {port} 240 | {/each} 241 |
242 | {/if} 243 |
244 | {/if} 245 | 246 |
247 |
248 | 249 |
250 |
251 |
Timeline
252 |
253 |
254 | 258 |
259 |
260 | 264 |
265 |
266 | 279 |
280 |
281 | 290 |
291 |
292 | 296 |
297 |
298 |
299 |
300 | 301 | {#if container.env || container.labels} 302 |
303 |
304 | 305 |
306 |
307 |
308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | {#each Object.entries(container.env).sort(([key1], [key2]) => key1.localeCompare(key2)) as [key, value]} 316 | 317 | 320 | 323 | 324 | {/each} 325 | 326 |
Environment Variables
318 | {key} 319 | 321 | {value} 322 |
327 |
328 |
329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | {#each Object.entries(container.labels).sort(([key1], [key2]) => key1.localeCompare(key2)) as [key, value]} 337 | 338 | 341 | 344 | 345 | {/each} 346 | 347 |
Labels
339 | {key} 340 | 342 | {value} 343 |
348 |
349 |
350 |
351 | {/if} 352 | {/if} 353 |
354 | 355 | 394 | -------------------------------------------------------------------------------- /client/src/containers/Filesystem.svelte: -------------------------------------------------------------------------------- 1 | 142 | 143 |
144 | {#if loading} 145 |
146 | Loading... 147 | 148 |
149 | {:else} 150 |
151 |
152 |
153 | {#if info} 154 | 157 | {/if} 158 | 161 |
162 | 163 |
164 | {#each parseCurrentDir() as itm} 165 | 166 | {/each} 167 |
168 |
169 | 170 |
171 | {#if allow_upload} 172 | 173 | Add 174 | Add Folder 175 | fileUploadElem.click()}>Upload File 176 | 177 | 178 | {/if} 179 | 180 | {#if info && info.mounts?.length > 0} 181 |
182 | 183 | Volumes 184 | {#each info.mounts as mount} 185 | loadPath(mount.destination)}> 186 |
187 | {mount.destination} 188 | 189 | {#if mount.readonly} 190 | readonly 191 | {/if} 192 | {mount.type} 193 | 194 |
195 |
196 | {/each} 197 |
198 |
199 | {/if} 200 |
201 |
202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | {#each currentEntries || [] as e} 215 | (e.type === 'd' ? gotoSubDir(e.name) : requestFileDownload(e.name))}> 216 | 229 | 234 | 238 | 241 | 244 | 245 | {/each} 246 | 247 |
NameSizeOwnerModifiedPermissions
217 | {#if e.type === 'd'} 218 | 219 | {:else if e.type === 'f'} 220 | 221 | {:else if e.type === 'l'} 222 | 223 | {:else} 224 | {e.type} 225 | {/if} 226 | 227 | {e.name} 228 | 230 | {#if e.type === 'f'} 231 | {e.filesize} 232 | {/if} 233 | 235 | {e.owner} 236 | {e.group} 237 | 239 | {e.modtime} 240 | 242 | {e.permissions} 243 |
248 | {/if} 249 |
250 | 251 | 256 | -------------------------------------------------------------------------------- /client/src/containers/Index.svelte: -------------------------------------------------------------------------------- 1 | 72 | 73 | {#if loading} 74 | 75 | {/if} 76 |
77 | 84 | {#if showContainerListOverlay} 85 |
86 | selectContainer(e.detail)} {containers} selectedContainer={showHostInfo ? null : selectedContainer} /> 87 |
88 | {/if} 89 |
90 |
91 | 94 | {#if showHostInfo} 95 | 96 | {:else} 97 |
98 |
99 |
    100 | {#if selectedContainer?.permissions?.includes('info')} 101 |
  • 102 | 103 |
  • 104 | {/if} 105 | {#if selectedContainer?.permissions?.includes('logs')} 106 |
  • 107 | 108 |
  • 109 | {/if} 110 | {#if isSelectedContainerRunning && selectedContainer?.permissions?.includes('term')} 111 |
  • 112 | 113 |
  • 114 | {/if} 115 | {#if isSelectedContainerRunning && selectedContainer?.permissions?.includes('procs')} 116 |
  • 117 | 118 |
  • 119 | {/if} 120 | {#if isSelectedContainerRunning && selectedContainer?.permissions?.includes('files')} 121 |
  • 122 | 123 |
  • 124 | {/if} 125 |
126 |
127 | {#if selectedContainer} 128 | {#if tab === 'info'} 129 |
130 | 131 |
132 | {:else if tab === 'logs'} 133 |
134 | 135 |
136 | {:else if tab === 'term'} 137 |
138 | 139 |
140 | {:else if tab === 'procs'} 141 |
142 | 143 |
144 | {:else if tab === 'files'} 145 |
146 | 151 |
152 | {/if} 153 | {/if} 154 |
155 |
156 |
157 | {/if} 158 |
159 | 160 | 162 | -------------------------------------------------------------------------------- /client/src/containers/LogViewer.svelte: -------------------------------------------------------------------------------- 1 | 79 | 80 |
81 | {#if loading} 82 |
83 | Loading... 84 | 85 |
86 | {:else if logs.length === 0} 87 |

No Log Output yet ...

88 | {:else} 89 |
90 |
91 | 96 |
97 |
98 | 101 | 104 | 107 | 110 |
111 |
112 | 113 |
{#each logs as line}{@const timestamp = line.substring(0, line.indexOf(' '))}{@const text = line.substring(line.indexOf(' ') + 1)}{#if timestampMode !== 'off'}{/if}{text}{/each}
119 | {/if} 120 |
121 | 122 | 160 | -------------------------------------------------------------------------------- /client/src/containers/Processes.svelte: -------------------------------------------------------------------------------- 1 | 53 | 54 |
55 | {#if loading} 56 |
57 | Loading... 58 | 59 |
60 | {:else} 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | {#each procs || [] as proc} 74 | 75 | 78 | 83 | 88 | 91 | 94 | 108 | 109 | {/each} 110 | 111 |
PIDCPUMemoryStarted AtUserCommand
76 | {proc.pid} 77 | 79 | {#if valueOrDefault(proc['%cpu'], null)} 80 | {proc['%cpu']}% 81 | {/if} 82 | 84 | {#if valueOrDefault(proc['%mem'], null)} 85 | {proc['%mem']}% 86 | {/if} 87 | 89 | {valueOrDefault(proc.stime, '')} 90 | 92 | {valueOrDefault(proc.user, '')} 93 | 95 |
96 | {#each Array(proc.level) as _, i} 97 |
98 | {#if parseInt(proc.level) === i + 1} 99 | 100 | {/if} 101 |
102 | {/each} 103 |
104 | {proc.command} 105 |
106 |
107 |
112 | {/if} 113 |
114 | 115 | 117 | -------------------------------------------------------------------------------- /client/src/containers/SystemInfo.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 |
34 | {#if loading} 35 |
36 | Loading... 37 | 38 |
39 | {:else if info} 40 |
41 |
42 |
Host
43 |
44 | 45 | {info.os} 46 |
47 |
48 | 49 | {info.cpus} CPUs 50 |
51 |
52 | 53 | {bytes2human(info.mem)} 54 | Memory 55 |
56 |
57 | 58 | {info.engine_version} 59 |
60 |
61 | 62 | {info.containers.running}/{info.containers.total} Containers running 63 |
64 | 70 |
71 |
72 | {/if} 73 |
74 | 75 | 77 | -------------------------------------------------------------------------------- /client/src/containers/Terminal.svelte: -------------------------------------------------------------------------------- 1 | 106 | 107 | 108 | 109 | {#if isTerminalOpen} 110 |
111 | 114 | 117 |
118 |
119 | {:else} 120 | 138 | {/if} 139 | -------------------------------------------------------------------------------- /client/src/containers/types.d.ts: -------------------------------------------------------------------------------- 1 | type ContainerStatus = 'created' | 'restarting' | 'running' | 'removing' | 'paused' | 'exited' | 'dead' 2 | type Tab = 'info' | 'logs' | 'term' | 'procs' | 'files' 3 | type PermissionType = 4 | 'info' 5 | | 'info-annotations' 6 | | 'state' 7 | | 'logs' 8 | | 'term' 9 | | 'procs' 10 | | 'files' 11 | | 'files-read' 12 | | 'files-write' 13 | 14 | class ContainerInfoShort { 15 | id: string 16 | name: string 17 | namespace: string 18 | status: ContainerStatus 19 | permissions: PermissionType[] 20 | } 21 | 22 | class ContainerInfoLong { 23 | id: string 24 | name: string 25 | status: ContainerStatus 26 | command: string 27 | created_at: string 28 | started_at: string 29 | finished_at: string 30 | crashes: number 31 | 'image': { 32 | 'name': string 33 | 'hash': string 34 | } 35 | 'mem': { 36 | 'used': number | null 37 | 'max_used': number | null 38 | 'total': number | null 39 | } | null 40 | 'cpu': { 41 | 'perc': number 42 | } | null 43 | 'net': { 44 | 'rx_bytes': number 45 | 'tx_bytes': number 46 | } | null 47 | ports: string[] 48 | env: { [key: string]: string } 49 | labels: { [key: string]: string } 50 | } 51 | 52 | class FilesystemInfo { 53 | workdir: string 54 | mounts: { source: string, destination: string, type: string, readonly: boolean }[] 55 | } 56 | 57 | class DirectoryListing { 58 | 'type': string 59 | 'permissions': string 60 | 'owner': string 61 | 'group': string 62 | 'filesize': string 63 | 'modtime': string 64 | 'name': string 65 | } 66 | 67 | class SysInfo { 68 | engine_version: string 69 | containers: { 70 | total: number 71 | running: number 72 | stopped: number 73 | } 74 | os: string 75 | cpus: number 76 | mem: number 77 | } 78 | 79 | class Proc { 80 | pid: string 81 | '%cpu': string 82 | '%mem': string 83 | stime: string 84 | user: string 85 | command: string 86 | level: string 87 | } 88 | -------------------------------------------------------------------------------- /client/src/containers/utils.ts: -------------------------------------------------------------------------------- 1 | export function round(value: number, postDecimal: number = 0) { 2 | return Math.round(value * 10 ** postDecimal) / 10 ** postDecimal 3 | } 4 | 5 | export function bytes2human(value: number) { 6 | const prefixes = ['', 'Ki', 'Mi', 'Gi', 'Ti'] 7 | let prefixCtr = 0 8 | while (value >= 1024) { 9 | value /= 1024 10 | prefixCtr++ 11 | } 12 | const preDecimal = Math.max(Math.floor(Math.log10(Math.floor(value))), 0) 13 | return round(value, 2 - preDecimal) + ' ' + prefixes[prefixCtr] + 'B' 14 | } 15 | 16 | export function fmtDate(date: string) { 17 | return new Date(date).toLocaleString(undefined, { 18 | year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' 19 | }) 20 | } 21 | 22 | export function downloadBlob(blob: Blob, filename: string) { 23 | // @ts-ignore 24 | if (window?.navigator?.msSaveOrOpenBlob) { 25 | // @ts-ignore 26 | window.navigator.msSaveOrOpenBlob(blob, filename) 27 | } else { 28 | const a = document.createElement('a') 29 | document.body.appendChild(a) 30 | const url = window.URL.createObjectURL(blob) 31 | a.href = url 32 | a.download = filename 33 | a.click() 34 | setTimeout(() => { 35 | window.URL.revokeObjectURL(url) 36 | document.body.removeChild(a) 37 | }, 0) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/src/dropdown/Dropdown.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | 19 | 38 | -------------------------------------------------------------------------------- /client/src/dropdown/DropdownDivider.svelte: -------------------------------------------------------------------------------- 1 |
  • 2 | 3 |
  • 4 | -------------------------------------------------------------------------------- /client/src/dropdown/DropdownItem.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
  • 6 | 9 |
  • 10 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import App from './App.svelte' 2 | import 'bootstrap-dark-5/dist/css/bootstrap-nightshade.min.css' 3 | import './app.css' 4 | import { mount } from 'svelte' 5 | 6 | const app = mount(App, { target: document.getElementById("app")! }) 7 | 8 | export default app 9 | -------------------------------------------------------------------------------- /client/src/messages/Message.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | 26 | 27 | {#if !dismissed} 28 | 61 | {/if} 62 | -------------------------------------------------------------------------------- /client/src/messages/MessageQueue.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    14 | 15 | {#each msgs as msg} 16 |
    22 |
    23 |
    24 | {msg.text} 25 |
    26 | 28 |
    29 |
    30 | {/each} 31 |
    32 | 33 | 36 | -------------------------------------------------------------------------------- /client/src/messages/message-store.ts: -------------------------------------------------------------------------------- 1 | import { get, writable } from 'svelte/store' 2 | 3 | const MSG_LIFETIME = 10 //secs 4 | 5 | export const newQueueStore = function () { 6 | let store = writable([]) 7 | 8 | setInterval(() => { 9 | let q = get(store) 10 | const now = Math.floor(new Date().getTime() / 1000) 11 | const filtered = q.filter(msg => msg.created_at! + MSG_LIFETIME > now) 12 | if (filtered.length !== q.length) 13 | store.set(filtered) 14 | }, 1000) 15 | 16 | return { 17 | add(msg: Message) { 18 | store.update(updater => ([...updater, { ...msg, created_at: Math.floor(new Date().getTime() / 1000) }])) 19 | }, 20 | remove(msg: Message) { 21 | store.update(updater => updater.filter(m => m.created_at !== msg.created_at || m.type !== msg.type || m.text !== msg.text)) 22 | }, 23 | subscribe: store.subscribe, 24 | } 25 | } 26 | 27 | export const messageBus = newQueueStore() 28 | 29 | -------------------------------------------------------------------------------- /client/src/messages/types.d.ts: -------------------------------------------------------------------------------- 1 | class Message { 2 | type: 'error' | 'warning' | 'info' | 'success' 3 | text: string 4 | created_at?: number 5 | } 6 | 7 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /client/svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 2 | 3 | export default { 4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: vitePreprocess(), 7 | } 8 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "resolveJsonModule": true, 8 | /** 9 | * Typecheck JS in `.svelte` and `.js` files by default. 10 | * Disable checkJs if you'd like to use dynamic types in JS. 11 | * Note that setting allowJs false does not prevent the use 12 | * of JS in `.svelte` files. 13 | */ 14 | "allowJs": true, 15 | "checkJs": true, 16 | "isolatedModules": true 17 | }, 18 | "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"], 19 | "references": [{ "path": "./tsconfig.node.json" }] 20 | } 21 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { svelte } from '@sveltejs/vite-plugin-svelte' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [svelte()], 7 | base: '', // is "/" per default (static path) so serving from a subfolder does not work => empty base equals "./" (relative paths) 8 | }) 9 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knrdl/caasa/45096bad0bed3eb9bbf8f300f40ac89835fcb3cb/screenshot.png -------------------------------------------------------------------------------- /server/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import aiohttp 4 | from config import AUTH_API_FIELD_PASSWORD, AUTH_API_FIELD_USERNAME, AUTH_API_URL, WEBPROXY_AUTH_HEADER 5 | from starlette.datastructures import State 6 | 7 | 8 | async def auth_api_login(username: str, password: str) -> None: 9 | if AUTH_API_URL: 10 | async with aiohttp.ClientSession() as session: 11 | async with session.post(AUTH_API_URL, json={AUTH_API_FIELD_USERNAME: username, 12 | AUTH_API_FIELD_PASSWORD: password}) as response: 13 | if not response.ok: 14 | raise Exception(await response.text()) 15 | else: 16 | raise Exception('auth api login is disabled') 17 | 18 | 19 | async def webproxy_login(state: State): 20 | username: Optional[str] = None 21 | if WEBPROXY_AUTH_HEADER: 22 | username = state.websocket.headers.get(WEBPROXY_AUTH_HEADER, None) 23 | if not username: 24 | raise Exception( 25 | f'web proxy auth has been configured but the http request lacks the required header "{WEBPROXY_AUTH_HEADER}"') 26 | return username 27 | -------------------------------------------------------------------------------- /server/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Dict, Final, Literal, Set, get_args 3 | 4 | from logger import logger 5 | 6 | AUTH_API_URL = os.getenv('AUTH_API_URL') 7 | AUTH_API_FIELD_USERNAME = os.getenv('AUTH_API_FIELD_USERNAME', 'username') 8 | AUTH_API_FIELD_PASSWORD = os.getenv('AUTH_API_FIELD_PASSWORD', 'password') 9 | WEBPROXY_AUTH_HEADER = os.getenv('WEBPROXY_AUTH_HEADER') 10 | if not AUTH_API_URL and not WEBPROXY_AUTH_HEADER: 11 | raise Exception('No authentication method given. Please provide the environment variables.') 12 | if AUTH_API_URL and WEBPROXY_AUTH_HEADER: 13 | raise Exception('WebForm authentication and WebProxy authentication cannot both be activated.') 14 | 15 | PermissionType = Literal[ 16 | 'info', 'info-annotations', 'state', 'logs', 'term', 'procs', 'files', 'files-read', 'files-write'] 17 | PERMISSIONS: Final[Set[PermissionType]] = set(get_args(PermissionType)) 18 | 19 | ROLES_PERMS: Dict[str, Set[PermissionType]] = {} 20 | for key, value in os.environ.items(): 21 | if key.startswith('ROLES_'): 22 | role_name = key.removeprefix('ROLES_').strip().replace('_', '.') 23 | if role_name: 24 | permissions = {p.strip() for p in value.split(',')} 25 | permissions = {p for p in permissions if p} 26 | unknown_permission = next((p for p in permissions if p not in PERMISSIONS), None) 27 | if unknown_permission: 28 | raise Exception(f'unknown permission "{unknown_permission}" for role "{role_name}"') 29 | ROLES_PERMS[role_name] = permissions 30 | 31 | if not ROLES_PERMS: 32 | raise Exception('no roles defined, please set ROLES_* env vars') 33 | 34 | logger.info('Roles -> Permissions:') 35 | for role in sorted(ROLES_PERMS): 36 | logger.info('* ' + role + ' -> ' + ', '.join(sorted(ROLES_PERMS[role]))) 37 | logger.info('') 38 | -------------------------------------------------------------------------------- /server/docker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import io 3 | import os.path 4 | import re 5 | import tarfile 6 | import traceback 7 | from typing import Dict, List, Literal, Set 8 | 9 | import aiodocker 10 | import config 11 | from aiodocker.containers import DockerContainer 12 | from aiodocker.stream import Stream 13 | 14 | client = aiodocker.Docker() 15 | 16 | 17 | def _get_labels(container: DockerContainer) -> Dict[str, str]: 18 | if 'Labels' in container._container: 19 | return container['Labels'] 20 | elif 'Config' in container._container: 21 | return container['Config'].get('Labels', {}) 22 | else: 23 | return {} 24 | 25 | 26 | def get_user_container_permissions(username: str, container: DockerContainer) -> Set[config.PermissionType]: 27 | username = username.lower() 28 | perms = set() 29 | try: 30 | labels = _get_labels(container) 31 | for role in config.ROLES_PERMS: 32 | if any((name.strip().lower() == username for name in labels.get(role, '').split(','))): # if user has role 33 | perms.update(config.ROLES_PERMS[role]) 34 | except Exception: 35 | traceback.print_exc() 36 | return perms 37 | 38 | 39 | async def get_system_info(username: str): 40 | # show only system info if user has access to at least one container 41 | if await get_user_containers(username): 42 | data = await client.system.info() 43 | if 'BuildahVersion' in data: 44 | engine = 'Podman' 45 | else: 46 | engine = 'Docker' 47 | return { 48 | 'engine_version': engine + ' ' + data['ServerVersion'], 49 | 'containers': { 50 | 'total': data['Containers'], 51 | 'running': data['ContainersRunning'], 52 | 'stopped': data['ContainersStopped'] 53 | }, 54 | 'os': data['OperatingSystem'], 55 | 'cpus': data['NCPU'], 56 | 'mem': data['MemTotal'] 57 | } 58 | else: 59 | raise Exception('Found no containers assigned to you.') 60 | 61 | 62 | async def get_user_containers(username: str): 63 | output = [] 64 | role_filter = [f'label={role}' for role in config.ROLES_PERMS] 65 | containers = await client.containers.list(filter=role_filter, all=True) 66 | for container in containers: 67 | permissions = get_user_container_permissions(username, container) 68 | if permissions: 69 | labels = container['Labels'] 70 | namespace = labels.get('com.docker.compose.project') 71 | name = (labels.get('com.docker.compose.service') if namespace else container['Names'][0]).title() 72 | output.append({ 73 | 'id': container.id, 74 | 'name': name.replace('-', ' ').replace('_', ' ').removeprefix('/'), 75 | 'namespace': namespace, 76 | 'status': container['State'], 77 | 'permissions': list(permissions) 78 | }) 79 | output.sort(key=lambda x: f"{x['namespace']} {x['name']}") 80 | return output 81 | 82 | 83 | async def fetch_logs(username: str, container_id: str, since: int): 84 | container = await client.containers.get(container_id) 85 | if 'logs' in get_user_container_permissions(username, container): 86 | loglines = [] 87 | async with container.docker._query(f'containers/{container_id}/logs', 88 | params={'stdout': True, 'stderr': True, 'timestamps': True, 89 | 'since': since, 'tail': 5000}) as response: 90 | while True: 91 | msg = await response.content.readline() 92 | if not msg: 93 | break 94 | msg_header = msg[:8] # first 8 bytes are usually header 95 | stdout, stderr = 0x01, 0x02 96 | if msg_header[0] in (stdout, stderr): # message has header 97 | loglines.append(msg[8:].decode('utf-8')) 98 | else: # some docker versions leave the header out 99 | loglines.append(msg.decode('utf-8')) 100 | return loglines 101 | else: 102 | raise Exception('unauthorized to access container') 103 | 104 | 105 | async def get_container_info(username: str, container_id: str): 106 | container = await client.containers.get(container_id) 107 | permissions = get_user_container_permissions(username, container) 108 | if 'info' in permissions: 109 | running = container['State']['Status'] == 'running' 110 | env_vars = {} 111 | if 'info-annotations' in permissions: 112 | for env in container['Config']['Env']: 113 | key, value = env.split('=', 1) 114 | env_vars[key] = value 115 | else: 116 | env_vars = None 117 | if running: 118 | stats = await container.stats(stream=False) 119 | 120 | stats = stats[0] 121 | 122 | try: 123 | cpu_delta = stats['cpu_stats']['cpu_usage']['total_usage'] - stats['precpu_stats']['cpu_usage'][ 124 | 'total_usage'] 125 | system_cpu_delta = stats['cpu_stats']['system_cpu_usage'] - stats['precpu_stats']['system_cpu_usage'] 126 | online_cpus = stats['cpu_stats']['online_cpus'] or len(stats['cpu_stats']['cpu_usage']['percpu_usage']) 127 | cpu_perc = (cpu_delta / system_cpu_delta) * online_cpus * 100.0 128 | except KeyError: 129 | cpu_perc = None 130 | 131 | mem_used = mem_used_max = mem_total = None 132 | try: 133 | if 'usage' in stats['memory_stats']: 134 | mem_used = stats['memory_stats']['usage'] - stats['memory_stats'].get('stats', {}).get('cache', 0) 135 | mem_used_max = stats['memory_stats'].get('max_usage', None) 136 | mem_total = stats['memory_stats'].get('limit', None) 137 | except KeyError: 138 | mem_used = mem_used_max = mem_total = None 139 | 140 | if mem_total is not None and mem_total == mem_used_max and mem_total > 1024 ** 5: 141 | mem_total = mem_used_max = None 142 | 143 | rx_bytes = sum([v['rx_bytes'] for v in stats.get('networks', {}).values()]) 144 | tx_bytes = sum([v['tx_bytes'] for v in stats.get('networks', {}).values()]) 145 | else: 146 | cpu_perc = mem_used = mem_used_max = mem_total = rx_bytes = tx_bytes = None 147 | if len(container['Args']) > 0 and container['Path'] == container['Args'][0]: 148 | cmd = ' '.join(container['Args']) 149 | else: 150 | cmd = ' '.join([container['Path'], *container['Args']]) 151 | return { 152 | 'id': container.id, 153 | 'name': container['Name'], 154 | 'status': container['State']['Status'], 155 | 'command': cmd.removeprefix('/bin/sh -c '), 156 | 'created_at': container['Created'], 157 | 'started_at': container['State']['StartedAt'], 158 | 'finished_at': container['State']['FinishedAt'], 159 | 'crashes': container['RestartCount'], 160 | 'env': env_vars, 161 | 'labels': _get_labels(container) if 'info-annotations' in permissions else None, 162 | 'image': { 163 | 'name': container['Config']['Image'], 164 | 'hash': container['Image'] 165 | }, 166 | 'mem': {'used': mem_used, 'max_used': mem_used_max, 'total': mem_total} if running else None, 167 | 'cpu': {'perc': cpu_perc} if cpu_perc is not None else None, 168 | 'net': {'rx_bytes': rx_bytes, 'tx_bytes': tx_bytes} if running else None, 169 | 'ports': sorted(container['NetworkSettings'].get('Ports', {}).keys(), key=lambda p: int(p.split('/')[0])) 170 | } 171 | else: 172 | raise Exception('unauthorized to access container') 173 | 174 | 175 | async def get_processes(username: str, container_id: str): 176 | container: DockerContainer = await client.containers.get(container_id) 177 | if 'procs' in get_user_container_permissions(username, container): 178 | try: 179 | data = await container.docker._query_json( 180 | f'containers/{container_id}/top', method='GET', 181 | params={'ps_args': 'ax -o pid,ppid,%cpu,%mem,user,stime,command'} 182 | ) 183 | except Exception: 184 | data = await container.docker._query_json( 185 | f'containers/{container_id}/top', method='GET', 186 | params={'ps_args': 'ax -o pid,ppid,user,comm'} 187 | ) 188 | if len(data['Titles']) == 1: 189 | data['Titles'] = data['Titles'][0].split() 190 | data['Processes'] = [(p[0].split() if len(p) == 1 else p) for p in data['Processes']] 191 | titles = [t.lower() for t in data['Titles']] 192 | procs = [] 193 | for proc in data['Processes']: 194 | procs.append(dict(zip(titles, proc))) 195 | for proc in procs: 196 | proc['hierarchy'] = [int(proc['pid'])] 197 | ppid = proc['ppid'] 198 | while True: 199 | parent_process = next((p for p in procs if p['pid'] == ppid), None) 200 | if parent_process: 201 | proc['hierarchy'].insert(0, int(parent_process['pid'])) 202 | ppid = parent_process['ppid'] 203 | if parent_process['ppid'] == parent_process['pid']: 204 | break 205 | else: 206 | break 207 | procs.sort(key=lambda p: p['hierarchy']) 208 | for proc in procs: 209 | proc['level'] = len(proc['hierarchy']) - 1 210 | del proc['hierarchy'] 211 | return procs 212 | else: 213 | raise Exception('unauthorized to access container') 214 | 215 | 216 | async def set_container_state(username: str, container_id: str, state: Literal['start', 'stop', 'restart']): 217 | container: DockerContainer = await client.containers.get(container_id) 218 | if 'state' in get_user_container_permissions(username, container): 219 | if state == 'start': 220 | await container.start() 221 | elif state == 'stop': 222 | await container.stop() 223 | elif state == 'restart': 224 | await container.restart() 225 | else: 226 | raise Exception('unknown container state') 227 | else: 228 | raise Exception('unauthorized to access container') 229 | 230 | 231 | async def get_filesystem_info(username: str, container_id: str): 232 | container: DockerContainer = await client.containers.get(container_id) 233 | if 'files' in get_user_container_permissions(username, container): 234 | mounts = [] 235 | for mount in container['Mounts']: 236 | output = {'type': mount['Type'], 'destination': mount['Destination'], 'readonly': not mount['RW']} 237 | if mount['Type'] == 'volume': 238 | output['source'] = mount['Name'] 239 | else: 240 | output['source'] = mount['Source'] 241 | mounts.append(output) 242 | return {'workdir': container['Config']['WorkingDir'], 'mounts': mounts} 243 | else: 244 | raise Exception('unauthorized to access container') 245 | 246 | 247 | def _parse_path(path: str): 248 | path = path or '' 249 | if not path.startswith('/'): 250 | path = '/' + path 251 | path = os.path.abspath(path) 252 | if any(path.startswith(p) for p in {'/proc', '/sys', '/dev', '/run'}): 253 | raise Exception('permission denied') 254 | return path 255 | 256 | 257 | async def _exec_output(container: DockerContainer, cmd: List[str], timeout=10, **kwargs): 258 | process = await container.exec(cmd=cmd, **kwargs) 259 | async with process.start(timeout=timeout) as exe: 260 | while True: 261 | details = await process.inspect() 262 | if details['Running']: 263 | await asyncio.sleep(.1) 264 | else: 265 | break 266 | details = await process.inspect() 267 | if details['ExitCode'] != 0: 268 | raise Exception('error executing command') 269 | msg = '' 270 | while part := await exe.read_out(): 271 | msg += part.data.decode('utf8') 272 | return msg 273 | 274 | 275 | async def list_directory(username: str, container_id: str, path: str): 276 | container: DockerContainer = await client.containers.get(container_id) 277 | if 'files' in get_user_container_permissions(username, container): 278 | path = _parse_path(path) 279 | cmd = ['ls', '-1', '-A', '-h', '--full-time', '-l', path] 280 | output = await _exec_output(container, cmd=cmd) 281 | entries = [] 282 | for entry in output.splitlines(): 283 | m = re.search( 284 | r'^([a-z-])([rwx-]{9})\s+\d+\s+(\w+)\s+(\w+)\s+([\w\\.]+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})[\\.\d+]*\s+\+\d{4}\s+(.+)$', 285 | entry) 286 | if m: 287 | typ, permissions, owner, group, filesize, modtime, name = m.groups() 288 | if typ == 'l': # link 289 | name = name.split(' -> ')[0] 290 | entries.append( 291 | {'type': typ.replace('-', 'f'), 'permissions': permissions, 'owner': owner, 'group': group, 292 | 'filesize': filesize + 'B', 'modtime': modtime, 'name': name}) 293 | return {'entries': entries, 'path': path} 294 | else: 295 | raise Exception('unauthorized to access container') 296 | 297 | 298 | async def create_folder(username: str, container_id: str, path: str): 299 | container: DockerContainer = await client.containers.get(container_id) 300 | if 'files-write' in get_user_container_permissions(username, container): 301 | path = _parse_path(path) 302 | await _exec_output(container, cmd=['mkdir', '-p', path]) 303 | return {'path': path} 304 | else: 305 | raise Exception('unauthorized to access container') 306 | 307 | 308 | async def upload_file(username: str, container_id: str, path: str, content: bytes): 309 | container: DockerContainer = await client.containers.get(container_id) 310 | if 'files-write' in get_user_container_permissions(username, container): 311 | path = _parse_path(path) 312 | tardata = io.BytesIO() 313 | with tarfile.open(fileobj=tardata, mode='w') as f: 314 | info = tarfile.TarInfo(os.path.basename(path)) 315 | info.size = len(content) 316 | f.addfile(fileobj=io.BytesIO(content), tarinfo=info) 317 | tardata.seek(0) 318 | await container.put_archive(os.path.dirname(path), tardata) 319 | return {'path': path} 320 | else: 321 | raise Exception('unauthorized to access container') 322 | 323 | 324 | async def download_file(username: str, container_id: str, path: str): 325 | container: DockerContainer = await client.containers.get(container_id) 326 | if 'files-read' in get_user_container_permissions(username, container): 327 | path = _parse_path(path) 328 | tar = await container.get_archive(path) 329 | for item in tar: 330 | if os.path.basename(path) == item.name: 331 | return {'path': path}, tar.extractfile(item.name).read() 332 | else: 333 | raise Exception('unauthorized to access container') 334 | 335 | 336 | async def spawn_terminal(username: str, container_id: str, cmd: str, user: str): 337 | container: DockerContainer = await client.containers.get(container_id) 338 | if 'term' in get_user_container_permissions(username, container): 339 | process = await container.exec(cmd=cmd, tty=True, user=user, stdin=True) 340 | stream: Stream = process.start() 341 | return process, stream 342 | else: 343 | raise Exception('unauthorized to access container') 344 | -------------------------------------------------------------------------------- /server/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger('uvicorn') 4 | -------------------------------------------------------------------------------- /server/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import time 4 | import traceback 5 | from typing import Literal 6 | 7 | import auth 8 | import docker 9 | import ws 10 | from aiodocker.execs import Exec 11 | from aiodocker.stream import Message 12 | from starlette.applications import Starlette 13 | from starlette.datastructures import State 14 | from starlette.routing import Mount, WebSocketRoute 15 | from starlette.staticfiles import StaticFiles 16 | from starlette.websockets import WebSocketState 17 | 18 | 19 | @ws.on_connect() 20 | async def webproxy_auth(state: State): 21 | username = await auth.webproxy_login(state) 22 | if username: 23 | state.username = username.lower() 24 | return 'webproxy_auth', {'username': state.username} 25 | 26 | 27 | @ws.cmd(auth=False) 28 | async def login(state: State, username: str, password: str): 29 | await auth.auth_api_login(username, password) 30 | state.username = username.lower() 31 | return {'username': state.username} 32 | 33 | 34 | @ws.cmd(auth=True) 35 | async def get_system_info(state: State): 36 | return await docker.get_system_info(state.username) 37 | 38 | 39 | @ws.cmd(auth=True) 40 | async def get_container_list(state: State): 41 | return await docker.get_user_containers(state.username) 42 | 43 | 44 | @ws.cmd(auth=True) 45 | async def get_container_logs(state: State, container_id: str, onlynew: bool = False): 46 | if not hasattr(state, 'logs'): 47 | state.logs = {} 48 | if container_id not in state.logs: 49 | state.logs[container_id] = 1 50 | since = state.logs[container_id] 51 | state.logs[container_id] = int(time.time()) 52 | return await docker.fetch_logs(state.username, container_id, since if onlynew else 1) 53 | 54 | 55 | @ws.cmd(auth=True) 56 | async def get_container_info(state: State, container_id: str): 57 | return await docker.get_container_info(state.username, container_id) 58 | 59 | 60 | @ws.cmd(auth=True) 61 | async def set_container_state(state: State, container_id: str, action: Literal['start', 'stop', 'restart']): 62 | await docker.set_container_state(state.username, container_id, action) 63 | return {'container_id': container_id, 'action': action} 64 | 65 | 66 | @ws.cmd(auth=True) 67 | async def get_processes(state: State, container_id: str): 68 | return await docker.get_processes(state.username, container_id) 69 | 70 | 71 | @ws.cmd(auth=True) 72 | async def get_filesystem_info(state: State, container_id: str): 73 | return await docker.get_filesystem_info(state.username, container_id) 74 | 75 | 76 | @ws.cmd(auth=True) 77 | async def get_directory_list(state: State, container_id: str, path: str): 78 | return await docker.list_directory(state.username, container_id, path) 79 | 80 | 81 | @ws.cmd(auth=True) 82 | async def download_file(state: State, container_id: str, path: str): 83 | return await docker.download_file(state.username, container_id, path) 84 | 85 | 86 | @ws.cmd(auth=True) 87 | async def create_folder(state: State, container_id: str, path: str): 88 | return await docker.create_folder(state.username, container_id, path) 89 | 90 | 91 | @ws.cmd(auth=True) 92 | async def upload_file(state: State, container_id: str, path: str, content: str): 93 | content = base64.b64decode(content) 94 | return await docker.upload_file(state.username, container_id, path, content) 95 | 96 | 97 | async def _listen_for_terminal_output(state: State): 98 | async def close_terminal_notify(): 99 | await close_terminal(state) 100 | await ws.send_json(state.websocket, 'close_terminal', {'is_closed': True}) 101 | 102 | while hasattr(state, 'term') and state.term: 103 | process, stream = state.term 104 | if state.websocket.client_state != WebSocketState.CONNECTED: 105 | await close_terminal_notify() 106 | break 107 | output: Message = await stream.read_out() 108 | if output: 109 | await ws.send_json_bytes(state.websocket, 'receive_terminal_output', json_payload=None, binary=output.data) 110 | else: 111 | await close_terminal_notify() 112 | break 113 | 114 | 115 | @ws.cmd(auth=True) 116 | async def spawn_terminal(state: State, container_id: str, cmd: str, user: str): 117 | process, stream = await docker.spawn_terminal(state.username, container_id, cmd, user) 118 | try: 119 | await close_terminal(state) 120 | except Exception: 121 | traceback.print_exc() 122 | state.term = process, stream 123 | asyncio.create_task(_listen_for_terminal_output(state)) 124 | return {'execId': process.id} 125 | 126 | 127 | @ws.cmd(auth=True) 128 | async def close_terminal(state: State): 129 | if hasattr(state, 'term') and state.term: 130 | process, stream = state.term 131 | state.term = None 132 | await stream.close() 133 | return {'is_closed': True} 134 | 135 | 136 | @ws.cmd(auth=True) 137 | async def transmit_terminal_input(state: State, data: str): 138 | if hasattr(state, 'term') and state.term: 139 | process, stream = state.term 140 | await stream.write_in(data.encode()) 141 | else: 142 | raise Exception('no terminal open') 143 | 144 | 145 | @ws.cmd(auth=True) 146 | async def resize_terminal(state: State, rows: int, cols: int): 147 | if hasattr(state, 'term') and state.term: 148 | process, stream = state.term 149 | process: Exec = process 150 | await process.resize(w=cols, h=rows) 151 | else: 152 | raise Exception('no terminal open') 153 | 154 | 155 | app = Starlette(debug=False, routes=[ 156 | WebSocketRoute('/ws', ws.WebSocketHandler), 157 | Mount('/', app=StaticFiles(directory='/www', html=True)), 158 | ]) 159 | -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | aiodocker==0.24.0 2 | aiohttp==3.12.6 3 | starlette==0.47.0 4 | uvicorn[standard]==0.34.3 5 | -------------------------------------------------------------------------------- /server/ws.py: -------------------------------------------------------------------------------- 1 | import json 2 | import traceback 3 | from typing import Any, Awaitable, Callable 4 | from urllib.parse import urlparse 5 | 6 | from logger import logger 7 | from starlette.datastructures import State 8 | from starlette.endpoints import WebSocketEndpoint 9 | from starlette.websockets import WebSocket 10 | 11 | AsyncFuncType = FuncType = Callable[[Any], Awaitable[Any]] 12 | 13 | _ws_actions: dict[str, AsyncFuncType] = {} 14 | _ws_on_connect_handler: list[AsyncFuncType] = [] 15 | 16 | 17 | def on_connect(): 18 | def wrapper(func: AsyncFuncType): 19 | _ws_on_connect_handler.append(func) 20 | 21 | return wrapper 22 | 23 | 24 | def cmd(auth: bool): 25 | def wrapper(func): 26 | if auth: 27 | _ws_actions[func.__name__] = _auth_required(func) 28 | else: 29 | _ws_actions[func.__name__] = func 30 | return _ws_actions[func.__name__] 31 | 32 | return wrapper 33 | 34 | 35 | def _auth_required(func): 36 | async def inner(state: State, **params): 37 | if state.username: 38 | return await func(state, **params) 39 | else: 40 | raise Exception('auth missing') 41 | 42 | return inner 43 | 44 | 45 | async def send_json_bytes(websocket: WebSocket, action: str, json_payload, binary: bytes): 46 | header = json.dumps({'response': action, 'payload': json_payload}).encode() 47 | await websocket.send_bytes(str(len(header)).encode() + b'!' + header + binary) 48 | 49 | 50 | async def send_json(websocket: WebSocket, action: str, json_payload): 51 | await websocket.send_json({'response': action, 'payload': json_payload}) 52 | 53 | 54 | class WebSocketHandler(WebSocketEndpoint): 55 | 56 | async def on_connect(self, websocket: WebSocket): 57 | # check for same request origin of webclient url and websocket opener 58 | # (needed because websocket isn't affected by CORS) 59 | origin = urlparse(websocket.headers.get('origin')) 60 | host = websocket.url 61 | if origin.netloc and host.netloc and origin.netloc == host.netloc: 62 | if origin.scheme != 'https': 63 | logger.warning('Insecure HTTP request detected. Please serve the application via HTTPS.') 64 | await websocket.accept() 65 | await self.after_connect(websocket) 66 | else: 67 | logger.warning('Cross-Site WebSocket Hijacking detected. ' 68 | 'If the application is served behind a reverse-proxy, you maybe forgot to pass the host header.') 69 | await websocket.close() 70 | 71 | async def after_connect(self, websocket: WebSocket): 72 | websocket.state.websocket = websocket 73 | for handler in _ws_on_connect_handler: 74 | try: 75 | result = await handler(websocket.state) 76 | if isinstance(result, tuple) and len(result) == 2: 77 | action, response = result 78 | await send_json(websocket, action, response) 79 | elif result is not None: 80 | raise Exception('invalid response format for on_connect handler') 81 | except Exception as e: 82 | traceback.print_exc() 83 | await websocket.send_json({'response': 'connection_init', 'error': str(e)}) 84 | 85 | async def on_receive(self, websocket: WebSocket, data: str): 86 | body = json.loads(data) 87 | action, payload = body['request'], body.get('payload', {}) 88 | if action in _ws_actions: 89 | try: 90 | response = await _ws_actions[action](websocket.state, **payload) 91 | if response is not None: 92 | if isinstance(response, tuple) and len(response) == 2 and \ 93 | isinstance(response[0], dict) and isinstance(response[1], bytes): 94 | payload, binary = response 95 | await send_json_bytes(websocket, action, payload, binary) 96 | else: 97 | await send_json(websocket, action, response) 98 | except Exception as e: 99 | traceback.print_exc() 100 | await websocket.send_json({'response': action, 'error': str(e)}) 101 | else: 102 | await websocket.send_json({'response': action, 'error': 'unknown command'}) 103 | -------------------------------------------------------------------------------- /setup-dev/.env: -------------------------------------------------------------------------------- 1 | COMPOSE_PROJECT_NAME=caasadev 2 | -------------------------------------------------------------------------------- /setup-dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.4' 2 | 3 | services: 4 | caasa: 5 | build: .. 6 | restart: always 7 | environment: 8 | ROLES_caasa_admin_basic: info, state, logs, procs, files, files-read 9 | ROLES_caasa_admin_full: info, info-annotations, state, logs, term, procs, files, files-read, files-write 10 | AUTH_API_URL: https://example.org 11 | AUTH_API_FIELD_USERNAME: username 12 | AUTH_API_FIELD_PASSWORD: password 13 | WEBPROXY_AUTH_HEADER: Remote-User 14 | # ports: 15 | # - "8080:8080" 16 | volumes: 17 | # - /var/run/docker.sock:/var/run/docker.sock # DOCKER 18 | - /run/user/1000/podman/podman.sock:/var/run/docker.sock # PODMAN 19 | networks: 20 | - net 21 | mem_limit: 150m 22 | cpu_count: 1 23 | labels: 24 | traefik.http.routers.caasa.rule: Host(`localhost`) 25 | traefik.http.middlewares.webproxy-auth.headers.customrequestheaders.Remote-User: user1 # for web proxy auth 26 | traefik.http.routers.caasa.middlewares: 'webproxy-auth@docker' 27 | 28 | proxy: 29 | image: docker.io/traefik:v2.9 30 | command: --providers.docker 31 | ports: 32 | - "127.0.0.1:8080:80" 33 | networks: 34 | - net 35 | volumes: 36 | # - /var/run/docker.sock:/var/run/docker.sock # DOCKER 37 | - /run/user/1000/podman/podman.sock:/var/run/docker.sock # PODMAN 38 | labels: 39 | caasa.admin.basic: user1,user2 40 | 41 | demo1: 42 | image: docker.io/nginx:alpine 43 | labels: 44 | caasa.admin.full: user1,user2 45 | 46 | demo2: 47 | image: docker.io/traefik/whoami 48 | labels: 49 | caasa.admin.basic: user1,user2 50 | 51 | networks: 52 | net: 53 | --------------------------------------------------------------------------------