├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ └── release-images.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── api │ └── webhook │ │ └── route.ts ├── globals.css ├── icon.svg ├── layout.tsx └── page.tsx ├── bun.lock ├── components.json ├── components ├── ActionButtons.tsx ├── AutoRefresh.tsx ├── ContainerDashboard.tsx ├── EventTable.tsx ├── StackTable.tsx ├── theme-provider.tsx └── ui │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── drawer.tsx │ ├── input.tsx │ ├── label.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── table.tsx │ └── tooltip.tsx ├── docker-compose.yml ├── lib ├── db.ts ├── process.ts └── utils.ts ├── middleware.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── prisma ├── migrations │ ├── 20250710011123_init │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── file.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/charts 15 | **/docker-compose* 16 | **/compose* 17 | **/Dockerfile* 18 | **/node_modules 19 | !.next/standalone/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | README.md 25 | config/ 26 | k3d/ -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: "npm" 5 | # Look for `package.json` and `lock` files in the `root` directory 6 | directory: "/" 7 | # Check the npm registry for updates every day (weekdays) 8 | schedule: 9 | interval: "daily" 10 | 11 | # Enable version updates for Docker 12 | - package-ecosystem: "docker" 13 | # Look for a `Dockerfile` in the `root` directory 14 | directory: "/" 15 | # Check for updates once a week 16 | schedule: 17 | interval: "weekly" 18 | -------------------------------------------------------------------------------- /.github/workflows/release-images.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Images 2 | on: 3 | release: 4 | types: [published] 5 | 6 | env: 7 | REGISTRY: ghcr.io 8 | IMAGE_PREFIX: ${{ github.repository }} 9 | 10 | jobs: 11 | build-and-push: 12 | name: Build and Push (amd64 + arm64) 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | id-token: write 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Log in to GitHub Container Registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Extract metadata 33 | id: meta 34 | uses: docker/metadata-action@v5 35 | with: 36 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }} 37 | tags: | 38 | type=semver,pattern={{version}} 39 | type=semver,pattern={{major}}.{{minor}} 40 | type=raw,value=latest,enable=${{ github.ref == format('refs/tags/{0}', github.event.release.tag_name) }} 41 | 42 | - name: Build and push multi-arch image 43 | uses: docker/build-push-action@v6 44 | with: 45 | context: . 46 | file: ./Dockerfile 47 | push: true 48 | platforms: linux/amd64,linux/arm64 49 | tags: ${{ steps.meta.outputs.tags }} 50 | labels: ${{ steps.meta.outputs.labels }} 51 | build-args: | 52 | NEXT_PUBLIC_APP_VERSION=${{ github.event.release.tag_name }} 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /dev 5 | /node_modules 6 | /.pnp 7 | .pnp.* 8 | .yarn/* 9 | !.yarn/patches 10 | !.yarn/plugins 11 | !.yarn/releases 12 | !.yarn/versions 13 | 14 | # testing 15 | /coverage 16 | 17 | # next.js 18 | /.next/ 19 | /out/ 20 | 21 | # production 22 | /build 23 | 24 | # misc 25 | .DS_Store 26 | *.pem 27 | 28 | # debug 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | .pnpm-debug.log* 33 | 34 | # env files (can opt-in for committing if needed) 35 | .env* 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | prisma/dev.db 44 | prisma/dev.db 45 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Node.js Alpine image as base 2 | FROM node:24-alpine AS base 3 | 4 | # Install dependencies only when needed 5 | FROM base AS deps 6 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 7 | RUN apk add --no-cache libc6-compat 8 | 9 | # Set working directory 10 | WORKDIR /app 11 | 12 | # Install pnpm 13 | RUN npm install -g pnpm 14 | 15 | # Copy package files 16 | COPY package.json pnpm-lock.yaml* ./ 17 | 18 | # Install dependencies (skip postinstall scripts to avoid Prisma generate) 19 | RUN pnpm install --frozen-lockfile --ignore-scripts 20 | 21 | # Rebuild the source code only when needed 22 | FROM base AS builder 23 | WORKDIR /app 24 | 25 | # Install pnpm 26 | RUN npm install -g pnpm 27 | 28 | # Copy node_modules from deps stage 29 | COPY --from=deps /app/node_modules ./node_modules 30 | 31 | # Copy source code 32 | COPY . . 33 | 34 | # Generate Prisma client 35 | RUN pnpm prisma generate 36 | 37 | # Build the application 38 | RUN pnpm build 39 | 40 | # Production image, copy all the files and run next 41 | FROM base AS runner 42 | WORKDIR /app 43 | 44 | # Install required packages including Docker CLI 45 | RUN apk add --no-cache docker-cli docker-cli-compose su-exec git 46 | 47 | # Don't run production as root 48 | #RUN addgroup --system --gid 1001 nodejs 49 | #RUN adduser --system --uid 1001 nextjs 50 | 51 | # Add nextjs user to docker group for Docker socket access 52 | #RUN addgroup -g 999 docker || true 53 | #RUN adduser nextjs docker 54 | 55 | # Copy the public folder from the project as this is not included in the build process 56 | COPY --from=builder /app/public ./public 57 | 58 | # Set the correct permission for prerender cache 59 | RUN mkdir .next 60 | RUN mkdir /app/data 61 | 62 | # Copy the build output (this includes node_modules and server.js) 63 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 64 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 65 | 66 | # Copy Prisma schema for potential runtime needs 67 | COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma 68 | 69 | # Install pnpm in the final stage for migrations 70 | RUN npm install -g pnpm prisma 71 | 72 | ARG NEXT_PUBLIC_APP_VERSION 73 | ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION 74 | 75 | # Expose port 76 | EXPOSE 3000 77 | 78 | # Set environment variables 79 | ENV PORT 3000 80 | ENV NODE_ENV production 81 | 82 | RUN ls -la /app/ 83 | 84 | # Start the application with migrations 85 | CMD ["sh", "-c", "echo 'Running Prisma migrations...' && npx prisma migrate deploy && echo 'Starting Next.js server...' && exec node server.js"] 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Declan Wade 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 | 2 | 3 | # SID - Simple Integration & Deployment 4 | 5 | sid screenshot 6 | 7 | SID is an opinionated, (almost) no-config service to provide a very simple way to have reliable GitOps for Docker Compose and GitHub. 8 | 9 | This project has three key objectives: 10 | 1. Provide a highly reliable way of deploying changes to `docker-compose` files from GitHub 11 | 2. Provide clear visibility on the status of each attempted deployment - whether it failed or succeeded 12 | 3. It must be as simple as possible while still achieving objective 1 and 2 13 | 14 | ### Why not Portainer or Komodo? 15 | These apps are excellent and far more powerful than SID - however they are significantly more complicated to setup. Generally they require configuring each stack individually along with the webhook. They also have differing ability to elegantly handle mono-repo setups. 16 | The interface of both these apps (particularly Komodo) can also be overwhelming for new users. 17 | 18 | ## Features 19 | 20 | - 🚀 With a correctly configured `docker-compose` file for SID, and a repo structured as per below - the service is ready to go, no further setup or configuration required! 21 | - 🪝 Provides a listener for GitHub event webhooks with signature verification 22 | - 💡 Context-aware deployments - the service checks to see which `docker-compose` files changed in the webhook event and only redeploys the stacks that have changed. No need for different branches or tags. 23 | - 🔐 Simple host validation out-of-the-box to provide basic security without needing an auth system 24 | - 👍 A simple web interface to view activity logs, review stack status, container list and basic controls to start, stop and remove individual containers 25 | - 📈 Basic database to capture and persist activity logs long-term 26 | - 🐙 The container includes `git`, so this does not need to be provided on the client 27 | 28 | ## Getting Started 29 | 30 | ### Prerequisites 31 | 32 | - Docker and Docker Compose: [Installation Guide](https://docs.docker.com/get-docker/) 33 | - A mono-repo on GitHub containing `docker-compose.yml` inside a folder at the repo root with the name of the stack. See example below: 34 | ``` 35 | my-compose-files/ <<--- this is the repo name 36 | ├── infrastructure/ 37 | │ └── docker-compose.yml 38 | ├── media/ 39 | │ └── docker-compose.yml 40 | └── pi-hole/ 41 | ├── docker-compose.yml 42 | └── config/ 43 | └── conf.json 44 | ``` 45 | - If your repo is **private**, a PAT token is required as an environment variable - this is explained further below in the docker config. 46 | 47 | > [!WARNING] 48 | > Be **very careful** with your `docker-compose` files if your repo is public, as often there are sensitive environment variables such as secret keys in plain text! Be mindful of your setup! 49 | 50 | ### Running with Docker (for end-users) 51 | 52 | **Option 1: Using a pre-built image (recomended)** 53 | 54 | Official images are published to GitHub Container Registry (GHCR) whenever a new [release](https://github.com/declan-wade/SID/releases) is created. 55 | 56 | You can pull the latest image using: 57 | ```bash 58 | docker pull ghcr.io/declan-wade/sid:latest 59 | ``` 60 | Or a specific version (e.g., `1.0.0`): 61 | ```bash 62 | docker pull ghcr.io/declan-wade/sid:1.0.0 63 | ``` 64 | Replace `1.0.0` with the desired version tag from the releases page. 65 | 66 | Then, when running the container, use the pulled image name (e.g., `ghcr.io/declan-wade/sid:latest` or `ghcr.io/declan-wade/sid:1.0.0`) instead of `sid-app`. 67 | Example `docker compose` command using a pre-built image: 68 | ```docker 69 | services: 70 | app: 71 | image: ghcr.io/declan-wade/sid:latest 72 | ports: 73 | - "3000:3000" 74 | environment: 75 | - SID_ALLOWED_HOSTS=localhost:3000 76 | - REPO_URL=https://@github.com// 77 | - REPO_NAME=compose-v2 78 | - WORKING_DIR=/home/user/sid/data 79 | - DB_URL=postgresql://admin:password@db:5432/sid 80 | - GITHUB_WEBHOOK_SECRET="abc" 81 | volumes: 82 | - ./sid/app/data:/app/data 83 | - /var/run/docker.sock:/var/run/docker.sock 84 | db: 85 | image: postgres 86 | restart: always 87 | volumes: 88 | - ./sid/app/db:/var/lib/postgresql/data 89 | environment: 90 | POSTGRES_USER: admin 91 | POSTGRES_PASSWORD: password 92 | POSTGRES_DB: sid 93 | ``` 94 | Further information is available below on each config option. 95 | 96 | **Option 2: Building the image locally** 97 | 98 | 1. Clone the repository: 99 | ```bash 100 | git clone https://github.com/declan-wade/SID.git 101 | cd SID 102 | ``` 103 | 2. Build the Docker image: 104 | ```bash 105 | docker build -t sid-app . 106 | ``` 107 | 108 | ## Configuration 109 | 110 | | Name | Required? | Description | Example | 111 | |-----------------------|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------| 112 | | SID_ALLOWED_HOSTS | No, for localhost only. Yes for non-localhost access to Web UI | This is the host and port SID is running on, that you want to externally access the Web UI on. This does not affect the webhook listener If you are only accessing the Web UI on localhost, this can be assigned `localhost:3000` | `10.1.1.10:3000` | 113 | | REPO_URL | Yes | The URL to your repo. NOTE: If your repo is private, you **must** provide a Personal Access Token (PAT) in this format: `https://@github.com//` | `https://github_pat_11AEXXXXX@github.com/john-smith/my-docker-compose` | 114 | | REPO_NAME | Yes | This is the name of your repository, without the organisation or username | my-docker-compose | 115 | | WORKING_DIR | See description | This is required **if** the mounts in your `docker-compose` files are using a relative path (e.g. your portainer instance has a volume bind mount like this `./portainer/data:/data`), but this does not matter if your using a _full path_ to your mounts (e.g. `/home/user/portainer/data:/data`). **Furthermore**, the path you provide here **must** be accessible to the host or else the docker service won't be able to bring up the containers. | Using the following environment variable: ` WORKING_DIR=/home/user/sid/data` with the following volume binding: `./data:/home/user/sid/data` | 116 | | DB_URL | Yes | For connecting to the postgres container. The default is fine, however you can change it if you know what your doing | `postgresql://admin:password@db:5432/sid` | 117 | | GITHUB_WEBHOOK_SECRET | If using webhooks | If your using a webhook to trigger deployments (recommended) then you must provide a secret that matches the secret provided when configuring a webhook in GitHub. GitHub does allow creation of webhooks without a secret however this will fail validation. | `abczyx` | 118 | 119 | Access the application by navigating to [http://localhost:3000](http://localhost:3000) (or your configured port) in your web browser. 120 | 121 | ## Using the Webhook 122 | 123 | > [!WARNING] 124 | > Exposing **anything** to the web poses some risk. Although SID has middleware to restrict access to the Web UI, **always** take appropriate measures to secure your endpoints, such as using a reverse proxy or zero-trust tunnel. 125 | 126 | The application exposes a `POST` endpoint on the `/api/webhook` route, which will need to be exposed to the web appropriately. For instance, if your service has an IP address of `10.1.1.10` and you have left the port as the default `3000`, then the route would be `10.1.1.10:3000/api/webhook` 127 | 128 | Follow the instructions [here](https://docs.github.com/en/webhooks/using-webhooks/creating-webhooks#creating-a-repository-webhook) on how to setup webhooks on your repo. **Please note** the following: 129 | 130 | Step 6 - use the `application/json` content type 131 | 132 | Step 7 - specifies this is optional, however SID is expecting a secret to validate when a request is triggered, so something needs to be provided here. 133 | 134 | Upon saving a new webhook, GitHub does a test ping request to check if the request is successful. This should come back as a success if everything is configured correctly. 135 | 136 | ## How it works 137 | 138 | The following diagram explains how the webhook trigger works, which is ready to run as soon as SID is brought up, no additional configuration is required. 139 | 140 | The "Sync GitHub" button in the UI is just for manually cloning/pulling the repo and syncing the actual structure of the compose directory with the database (this is possibly redundant, could maybe make this automatic when loading the UI) 141 | 142 | ```mermaid 143 | flowchart TD 144 | A[Webhook Trigger] --> B{Repository exists locally?} 145 | 146 | B -->|No| C[git clone repository] 147 | B -->|Yes| D[git pull latest changes] 148 | 149 | C --> E[Parse event payload for changed files] 150 | D --> E 151 | 152 | E --> F{Changed files contain docker-compose files?} 153 | 154 | F -->|No| G[End - No compose files to update] 155 | F -->|Yes| H[Extract directories with docker-compose changes] 156 | 157 | H --> I[For each affected directory] 158 | I --> J[Navigate to directory] 159 | J --> K[Execute: docker compose up -d --remove-orphans] 160 | 161 | K --> L{More directories?} 162 | L -->|Yes| I 163 | L -->|No| M[End - All compose files updated] 164 | 165 | K --> N{Command successful?} 166 | N -->|Yes| O[Log success event] 167 | N -->|No| P[Log error event] 168 | 169 | O --> L 170 | P --> L 171 | 172 | ``` 173 | 174 | ## Development Instructions 175 | 176 | > [!IMPORTANT] 177 | > This repo will work with either `npm`, `pnpm` or `bun` for local development purposes, however the `Dockerfile` at build stage will be expecting a frozen `pnpm` lockfile, so ensure this has been updated with `pnpm install` 178 | 179 | 1. **Clone the repository:** 180 | ```bash 181 | git clone https://github.com/declan-wade/SID.git 182 | cd SID 183 | ``` 184 | 2. **Install dependencies:** 185 | 186 | ```bash 187 | bun install 188 | ``` 189 | 3. **Set up environment variables:** 190 | For development, `DATABASE_URL` is already configured in `prisma/schema.prisma` to use `file:./dev.db`. 191 | If you need to change it (e.g., to use a different database file or a server-based database), you can create a `.env` file in the root of the project and set the `DATABASE_URL` there: 192 | ```env 193 | DATABASE_URL="file:./dev.db" 194 | ``` 195 | 196 | 4. **Database setup:** 197 | The project uses Prisma for ORM and Postgres as the database. 198 | To initialize/reset the database and apply migrations for development: 199 | ```bash 200 | npx prisma migrate dev 201 | ``` 202 | This will bootstrap a new database called `sid` with the permissions passed from the environment variables. 203 | 204 | 5. **Run the development server:** 205 | ```bash 206 | bun dev 207 | ``` 208 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 209 | 210 | 6. **Running tests:** 211 | (Instructions for running tests - To be filled if tests are added) 212 | 213 | ## Tech Stack 214 | 215 | - Next.js 216 | - React 217 | - TypeScript 218 | - ShadCn 219 | - Prisma 220 | - Postgres 221 | - Docker 222 | - Tailwind CSS 223 | 224 | ## Acknowledgements 225 | 226 | - Big thanks to the excellent [Homepage](https://github.com/gethomepage/homepage) project (and by extension [@shamoon](https://github.com/shamoon)) for inspiration on the `middleware` component allowing local host validation! 227 | 228 | ## Contributing 229 | 230 | Contributions are welcome! Please fork the repository, create a new branch for your feature or bug fix, and submit a pull request. 231 | 232 | ## License 233 | 234 | This project is licensed under the [MIT License](https://github.com/declan-wade/SID/tree/main?tab=MIT-1-ov-file). 235 | 236 | -------------------------------------------------------------------------------- /app/api/webhook/route.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { syncStacks } from "@/lib/db"; 4 | import { clone, runDockerComposeForChangedDirs } from "@/lib/process"; 5 | import { verify } from "@octokit/webhooks-methods"; 6 | 7 | export async function POST(request: Request) { 8 | try { 9 | const body = await request.text(); 10 | const signature = request.headers.get("X-Hub-Signature-256") ?? ""; 11 | 12 | if (!signature) { 13 | return new Response("Missing signature", { status: 401 }); 14 | } 15 | 16 | const isValid = await verify( 17 | process.env.GITHUB_WEBHOOK_SECRET || "", 18 | body, 19 | signature, 20 | ); 21 | 22 | if (!isValid) { 23 | console.log("Signature validation failed"); 24 | return new Response("Invalid signature", { status: 401 }); 25 | } 26 | 27 | console.log("Signature validation successful"); 28 | 29 | // Parse the JSON payload after verification 30 | const payload = JSON.parse(body); 31 | 32 | // Clone the repository 33 | await clone(); 34 | 35 | // Sync stacks with Database before any events are written to DB 36 | await syncStacks(); 37 | 38 | // Process commits 39 | const changedFiles: string[] = []; 40 | const commits = payload.commits || []; 41 | 42 | console.log(`Processing ${commits.length} commits from the repository.`); 43 | 44 | commits.forEach((commit: any) => { 45 | console.log(`Commit: ${commit.id} - ${commit.message}`); 46 | 47 | // Process modified files 48 | if (commit.modified) { 49 | commit.modified.forEach((file: string) => { 50 | console.log(`Modified file: ${file}`); 51 | changedFiles.push(file); 52 | }); 53 | } 54 | 55 | // Process added files 56 | if (commit.added) { 57 | commit.added.forEach((file: string) => { 58 | console.log(`Added file: ${file}`); 59 | changedFiles.push(file); 60 | }); 61 | } 62 | 63 | // Process removed files (optional) 64 | if (commit.removed) { 65 | commit.removed.forEach((file: string) => { 66 | console.log(`Removed file: ${file}`); 67 | changedFiles.push(file); 68 | }); 69 | } 70 | }); 71 | 72 | console.log(`Changed files: ${changedFiles.join(", ")}`); 73 | 74 | // Run Docker Compose for changed directories 75 | if (changedFiles.length > 0) { 76 | await runDockerComposeForChangedDirs(changedFiles); 77 | console.log("Docker Compose run completed for changed directories."); 78 | } else { 79 | console.log("No files changed, skipping Docker Compose run."); 80 | } 81 | 82 | return new Response("Webhook processed successfully", { status: 200 }); 83 | } catch (error) { 84 | console.error("Error processing webhook:", error); 85 | return new Response("Internal server error", { status: 500 }); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --color-background: var(--background); 8 | --color-foreground: var(--foreground); 9 | --font-sans: var(--font-geist-sans); 10 | --font-mono: var(--font-geist-mono); 11 | --color-sidebar-ring: var(--sidebar-ring); 12 | --color-sidebar-border: var(--sidebar-border); 13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 14 | --color-sidebar-accent: var(--sidebar-accent); 15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 16 | --color-sidebar-primary: var(--sidebar-primary); 17 | --color-sidebar-foreground: var(--sidebar-foreground); 18 | --color-sidebar: var(--sidebar); 19 | --color-chart-5: var(--chart-5); 20 | --color-chart-4: var(--chart-4); 21 | --color-chart-3: var(--chart-3); 22 | --color-chart-2: var(--chart-2); 23 | --color-chart-1: var(--chart-1); 24 | --color-ring: var(--ring); 25 | --color-input: var(--input); 26 | --color-border: var(--border); 27 | --color-destructive: var(--destructive); 28 | --color-accent-foreground: var(--accent-foreground); 29 | --color-accent: var(--accent); 30 | --color-muted-foreground: var(--muted-foreground); 31 | --color-muted: var(--muted); 32 | --color-secondary-foreground: var(--secondary-foreground); 33 | --color-secondary: var(--secondary); 34 | --color-primary-foreground: var(--primary-foreground); 35 | --color-primary: var(--primary); 36 | --color-popover-foreground: var(--popover-foreground); 37 | --color-popover: var(--popover); 38 | --color-card-foreground: var(--card-foreground); 39 | --color-card: var(--card); 40 | --radius-sm: calc(var(--radius) - 4px); 41 | --radius-md: calc(var(--radius) - 2px); 42 | --radius-lg: var(--radius); 43 | --radius-xl: calc(var(--radius) + 4px); 44 | } 45 | 46 | :root { 47 | --radius: 0.625rem; 48 | --background: oklch(1 0 0); 49 | --foreground: oklch(0.129 0.042 264.695); 50 | --card: oklch(1 0 0); 51 | --card-foreground: oklch(0.129 0.042 264.695); 52 | --popover: oklch(1 0 0); 53 | --popover-foreground: oklch(0.129 0.042 264.695); 54 | --primary: oklch(0.208 0.042 265.755); 55 | --primary-foreground: oklch(0.984 0.003 247.858); 56 | --secondary: oklch(0.968 0.007 247.896); 57 | --secondary-foreground: oklch(0.208 0.042 265.755); 58 | --muted: oklch(0.968 0.007 247.896); 59 | --muted-foreground: oklch(0.554 0.046 257.417); 60 | --accent: oklch(0.968 0.007 247.896); 61 | --accent-foreground: oklch(0.208 0.042 265.755); 62 | --destructive: oklch(0.577 0.245 27.325); 63 | --border: oklch(0.929 0.013 255.508); 64 | --input: oklch(0.929 0.013 255.508); 65 | --ring: oklch(0.704 0.04 256.788); 66 | --chart-1: oklch(0.646 0.222 41.116); 67 | --chart-2: oklch(0.6 0.118 184.704); 68 | --chart-3: oklch(0.398 0.07 227.392); 69 | --chart-4: oklch(0.828 0.189 84.429); 70 | --chart-5: oklch(0.769 0.188 70.08); 71 | --sidebar: oklch(0.984 0.003 247.858); 72 | --sidebar-foreground: oklch(0.129 0.042 264.695); 73 | --sidebar-primary: oklch(0.208 0.042 265.755); 74 | --sidebar-primary-foreground: oklch(0.984 0.003 247.858); 75 | --sidebar-accent: oklch(0.968 0.007 247.896); 76 | --sidebar-accent-foreground: oklch(0.208 0.042 265.755); 77 | --sidebar-border: oklch(0.929 0.013 255.508); 78 | --sidebar-ring: oklch(0.704 0.04 256.788); 79 | } 80 | 81 | .dark { 82 | --background: oklch(0.129 0.042 264.695); 83 | --foreground: oklch(0.984 0.003 247.858); 84 | --card: oklch(0.208 0.042 265.755); 85 | --card-foreground: oklch(0.984 0.003 247.858); 86 | --popover: oklch(0.208 0.042 265.755); 87 | --popover-foreground: oklch(0.984 0.003 247.858); 88 | --primary: oklch(0.929 0.013 255.508); 89 | --primary-foreground: oklch(0.208 0.042 265.755); 90 | --secondary: oklch(0.279 0.041 260.031); 91 | --secondary-foreground: oklch(0.984 0.003 247.858); 92 | --muted: oklch(0.279 0.041 260.031); 93 | --muted-foreground: oklch(0.704 0.04 256.788); 94 | --accent: oklch(0.279 0.041 260.031); 95 | --accent-foreground: oklch(0.984 0.003 247.858); 96 | --destructive: oklch(0.704 0.191 22.216); 97 | --border: oklch(1 0 0 / 10%); 98 | --input: oklch(1 0 0 / 15%); 99 | --ring: oklch(0.551 0.027 264.364); 100 | --chart-1: oklch(0.488 0.243 264.376); 101 | --chart-2: oklch(0.696 0.17 162.48); 102 | --chart-3: oklch(0.769 0.188 70.08); 103 | --chart-4: oklch(0.627 0.265 303.9); 104 | --chart-5: oklch(0.645 0.246 16.439); 105 | --sidebar: oklch(0.208 0.042 265.755); 106 | --sidebar-foreground: oklch(0.984 0.003 247.858); 107 | --sidebar-primary: oklch(0.488 0.243 264.376); 108 | --sidebar-primary-foreground: oklch(0.984 0.003 247.858); 109 | --sidebar-accent: oklch(0.279 0.041 260.031); 110 | --sidebar-accent-foreground: oklch(0.984 0.003 247.858); 111 | --sidebar-border: oklch(1 0 0 / 10%); 112 | --sidebar-ring: oklch(0.551 0.027 264.364); 113 | } 114 | 115 | @layer base { 116 | * { 117 | @apply border-border outline-ring/50; 118 | } 119 | body { 120 | @apply bg-background text-foreground; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /app/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import { ThemeProvider } from "@/components/theme-provider"; 4 | import { Toaster } from "@/components/ui/sonner"; 5 | import "./globals.css"; 6 | 7 | const geistSans = Geist({ 8 | variable: "--font-geist-sans", 9 | subsets: ["latin"], 10 | }); 11 | 12 | const geistMono = Geist_Mono({ 13 | variable: "--font-geist-mono", 14 | subsets: ["latin"], 15 | }); 16 | 17 | export const metadata: Metadata = { 18 | title: "SID", 19 | description: "Manage your container deployments with SID", 20 | }; 21 | 22 | export default function RootLayout({ 23 | children, 24 | }: Readonly<{ 25 | children: React.ReactNode; 26 | }>) { 27 | return ( 28 | <> 29 | 30 | 31 | 34 | 40 | {children} 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { check } from "../lib/process"; 2 | import ContainerDashboard from "@/components/ContainerDashboard"; 3 | import { getEvents, getStacks } from "@/lib/db"; 4 | import StackList from "@/components/StackTable"; 5 | import EventTable from "@/components/EventTable"; 6 | import { AutoRefresh } from "@/components/AutoRefresh"; 7 | import { Container } from "lucide-react"; 8 | 9 | export const revalidate = 60; 10 | 11 | export default async function Home(props: { 12 | searchParams?: Promise<{ 13 | query?: string; 14 | page?: string; 15 | }>; 16 | }) { 17 | const { searchParams } = props; 18 | const pageSize = 10; 19 | const resolvedSearchParams = searchParams ? await searchParams : {}; 20 | const currentPage = Number(resolvedSearchParams.page) || 1; 21 | let containers = []; 22 | const response: any = await check(); 23 | containers = response.containers; 24 | const stacks = await getStacks(); 25 | const { events, total } = await getEvents(currentPage, pageSize); 26 | 27 | return ( 28 |
29 |
30 |

31 | 32 | SID Dashboard 33 |

34 |
35 | 36 | {process.env.NEXT_PUBLIC_APP_VERSION ? ( 37 | 42 | SID {process.env.NEXT_PUBLIC_APP_VERSION!} 43 | 44 | ) : ( 45 | 50 | GitHub 51 | 52 | )} 53 |
54 |
55 |
56 | {containers && containers.length > 0 ? ( 57 | 58 | ) : ( 59 |
60 | Loading containers... 61 |
62 | )} 63 |
64 | 65 |
66 | 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "sid", 6 | "dependencies": { 7 | "@octokit/core": "^7.0.3", 8 | "@octokit/webhooks-methods": "^6.0.0", 9 | "@prisma/client": "^6.11.1", 10 | "@radix-ui/react-alert-dialog": "^1.1.14", 11 | "@radix-ui/react-dialog": "^1.1.6", 12 | "@radix-ui/react-label": "^2.1.2", 13 | "@radix-ui/react-slot": "^1.2.3", 14 | "@radix-ui/react-switch": "^1.2.5", 15 | "@radix-ui/react-tooltip": "^1.1.8", 16 | "class-variance-authority": "^0.7.1", 17 | "clsx": "^2.1.1", 18 | "date-fns": "^4.1.0", 19 | "lucide-react": "^0.487.0", 20 | "next": "15.2.4", 21 | "next-themes": "^0.4.6", 22 | "prisma": "^6.11.1", 23 | "react": "^19.1.0", 24 | "react-dom": "^19.1.0", 25 | "sonner": "^2.0.3", 26 | "tailwind-merge": "^3.1.0", 27 | "tw-animate-css": "^1.2.5", 28 | "vaul": "^1.1.2", 29 | }, 30 | "devDependencies": { 31 | "@tailwindcss/postcss": "^4", 32 | "@types/node": "^20", 33 | "@types/react": "^19", 34 | "@types/react-dom": "^19", 35 | "tailwindcss": "^4", 36 | "typescript": "^5", 37 | }, 38 | }, 39 | }, 40 | "packages": { 41 | "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], 42 | 43 | "@emnapi/runtime": ["@emnapi/runtime@1.4.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw=="], 44 | 45 | "@floating-ui/core": ["@floating-ui/core@1.7.2", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw=="], 46 | 47 | "@floating-ui/dom": ["@floating-ui/dom@1.7.2", "", { "dependencies": { "@floating-ui/core": "^1.7.2", "@floating-ui/utils": "^0.2.10" } }, "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA=="], 48 | 49 | "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.4", "", { "dependencies": { "@floating-ui/dom": "^1.7.2" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw=="], 50 | 51 | "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], 52 | 53 | "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], 54 | 55 | "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], 56 | 57 | "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], 58 | 59 | "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], 60 | 61 | "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], 62 | 63 | "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], 64 | 65 | "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], 66 | 67 | "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], 68 | 69 | "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], 70 | 71 | "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], 72 | 73 | "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], 74 | 75 | "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], 76 | 77 | "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], 78 | 79 | "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], 80 | 81 | "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], 82 | 83 | "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], 84 | 85 | "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], 86 | 87 | "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], 88 | 89 | "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], 90 | 91 | "@next/env": ["@next/env@15.2.4", "", {}, "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g=="], 92 | 93 | "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw=="], 94 | 95 | "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew=="], 96 | 97 | "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ=="], 98 | 99 | "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA=="], 100 | 101 | "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw=="], 102 | 103 | "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw=="], 104 | 105 | "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg=="], 106 | 107 | "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ=="], 108 | 109 | "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], 110 | 111 | "@octokit/core": ["@octokit/core@7.0.3", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.1", "@octokit/request": "^10.0.2", "@octokit/request-error": "^7.0.0", "@octokit/types": "^14.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-oNXsh2ywth5aowwIa7RKtawnkdH6LgU1ztfP9AIUCQCvzysB+WeU8o2kyyosDPwBZutPpjZDKPQGIzzrfTWweQ=="], 112 | 113 | "@octokit/endpoint": ["@octokit/endpoint@11.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ=="], 114 | 115 | "@octokit/graphql": ["@octokit/graphql@9.0.1", "", { "dependencies": { "@octokit/request": "^10.0.2", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg=="], 116 | 117 | "@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="], 118 | 119 | "@octokit/request": ["@octokit/request@10.0.3", "", { "dependencies": { "@octokit/endpoint": "^11.0.0", "@octokit/request-error": "^7.0.0", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA=="], 120 | 121 | "@octokit/request-error": ["@octokit/request-error@7.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg=="], 122 | 123 | "@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], 124 | 125 | "@octokit/webhooks-methods": ["@octokit/webhooks-methods@6.0.0", "", {}, "sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ=="], 126 | 127 | "@prisma/client": ["@prisma/client@6.11.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-5CLFh8QP6KxRm83pJ84jaVCeSVPQr8k0L2SEtOJHwdkS57/VQDcI/wQpGmdyOZi+D9gdNabdo8tj1Uk+w+upsQ=="], 128 | 129 | "@prisma/config": ["@prisma/config@6.11.1", "", { "dependencies": { "jiti": "2.4.2" } }, "sha512-z6rCTQN741wxDq82cpdzx2uVykpnQIXalLhrWQSR0jlBVOxCIkz3HZnd8ern3uYTcWKfB3IpVAF7K2FU8t/8AQ=="], 130 | 131 | "@prisma/debug": ["@prisma/debug@6.11.1", "", {}, "sha512-lWRb/YSWu8l4Yum1UXfGLtqFzZkVS2ygkWYpgkbgMHn9XJlMITIgeMvJyX5GepChzhmxuSuiq/MY/kGFweOpGw=="], 132 | 133 | "@prisma/engines": ["@prisma/engines@6.11.1", "", { "dependencies": { "@prisma/debug": "6.11.1", "@prisma/engines-version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9", "@prisma/fetch-engine": "6.11.1", "@prisma/get-platform": "6.11.1" } }, "sha512-6eKEcV6V8W2eZAUwX2xTktxqPM4vnx3sxz3SDtpZwjHKpC6lhOtc4vtAtFUuf5+eEqBk+dbJ9Dcaj6uQU+FNNg=="], 134 | 135 | "@prisma/engines-version": ["@prisma/engines-version@6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9", "", {}, "sha512-swFJTOOg4tHyOM1zB/pHb3MeH0i6t7jFKn5l+ZsB23d9AQACuIRo9MouvuKGvnDogzkcjbWnXi/NvOZ0+n5Jfw=="], 136 | 137 | "@prisma/fetch-engine": ["@prisma/fetch-engine@6.11.1", "", { "dependencies": { "@prisma/debug": "6.11.1", "@prisma/engines-version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9", "@prisma/get-platform": "6.11.1" } }, "sha512-NBYzmkXTkj9+LxNPRSndaAeALOL1Gr3tjvgRYNqruIPlZ6/ixLeuE/5boYOewant58tnaYFZ5Ne0jFBPfGXHpQ=="], 138 | 139 | "@prisma/get-platform": ["@prisma/get-platform@6.11.1", "", { "dependencies": { "@prisma/debug": "6.11.1" } }, "sha512-b2Z8oV2gwvdCkFemBTFd0x4lsL4O2jLSx8lB7D+XqoFALOQZPa7eAPE1NU0Mj1V8gPHRxIsHnyUNtw2i92psUw=="], 140 | 141 | "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], 142 | 143 | "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ=="], 144 | 145 | "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], 146 | 147 | "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], 148 | 149 | "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], 150 | 151 | "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], 152 | 153 | "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], 154 | 155 | "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], 156 | 157 | "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], 158 | 159 | "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], 160 | 161 | "@radix-ui/react-label": ["@radix-ui/react-label@2.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw=="], 162 | 163 | "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="], 164 | 165 | "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], 166 | 167 | "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], 168 | 169 | "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], 170 | 171 | "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 172 | 173 | "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ=="], 174 | 175 | "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw=="], 176 | 177 | "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], 178 | 179 | "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], 180 | 181 | "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], 182 | 183 | "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], 184 | 185 | "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], 186 | 187 | "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], 188 | 189 | "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], 190 | 191 | "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], 192 | 193 | "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], 194 | 195 | "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], 196 | 197 | "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], 198 | 199 | "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], 200 | 201 | "@tailwindcss/node": ["@tailwindcss/node@4.1.1", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "tailwindcss": "4.1.1" } }, "sha512-xvlh4pvfG/bkv0fEtJDABAm1tjtSmSyi2QmS4zyj1EKNI1UiOYiUq1IphSwDsNJ5vJ9cWEGs4rJXpUdCN2kujQ=="], 202 | 203 | "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.1", "@tailwindcss/oxide-darwin-arm64": "4.1.1", "@tailwindcss/oxide-darwin-x64": "4.1.1", "@tailwindcss/oxide-freebsd-x64": "4.1.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.1", "@tailwindcss/oxide-linux-arm64-musl": "4.1.1", "@tailwindcss/oxide-linux-x64-gnu": "4.1.1", "@tailwindcss/oxide-linux-x64-musl": "4.1.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.1", "@tailwindcss/oxide-win32-x64-msvc": "4.1.1" } }, "sha512-7+YBgnPQ4+jv6B6WVOerJ6WOzDzNJXrRKDts674v6TKAqFqYRr9+EBtSziO7nNcwQ8JtoZNMeqA+WJDjtCM/7w=="], 204 | 205 | "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.1", "", { "os": "android", "cpu": "arm64" }, "sha512-gTyRzfdParpoCU1yyUC/iN6XK6T0Ra4bDlF8Aeul5NP9cLzKEZDogdNVNGv5WZmCDkVol7qlex7TMmcfytMmmw=="], 206 | 207 | "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dI0QbdMWBvLB3MtaTKetzUKG9CUUQow8JSP4Nm+OxVokeZ+N+f1OmZW/hW1LzMxpx9RQCBgSRL+IIvKRat5Wdg=="], 208 | 209 | "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-2Y+NPQOTRBCItshPgY/CWg4bKi7E9evMg4bgdb6h9iZObCZLOe3doPcuSxGS3DB0dKyMFKE8pTdWtFUbxZBMSA=="], 210 | 211 | "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-N97NGMsB/7CHShbc5ube4dcsW/bYENkBrg8yWi8ieN9boYVRdw3cZviVryV/Nfu9bKbBV9kUvduFF2qBI7rEqg=="], 212 | 213 | "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.1", "", { "os": "linux", "cpu": "arm" }, "sha512-33Lk6KbHnUZbXqza6RWNFo9wqPQ4+H5BAn1CkUUfC1RZ1vYbyDN6+iJPj53wmnWJ3mhRI8jWt3Jt1fO02IVdUQ=="], 214 | 215 | "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LyW35RzSUy+80WYScv03HKasAUmMFDaSbNpWfk1gG5gEE9kuRGnDzSrqMoLAmY/kzMCYP/1kqmUiAx8EFLkI2A=="], 216 | 217 | "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1KPnDMlHdqjPTUSFjx55pafvs8RZXRgxfeRgUrukwDKkuj7gFk28vW3Mx65YdiugAc9NWs3VgueZWaM1Po6uGw=="], 218 | 219 | "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-4WdzA+MRlsinEEE6yxNMLJxpw0kE9XVipbAKdTL8BeUpyC2TdA3TL46lBulXzKp3BIxh3nqyR/UCqzl5o+3waQ=="], 220 | 221 | "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-q7Ugbw3ARcjCW2VMUYrcMbJ6aMQuWPArBBE2EqC/swPZTdGADvMQSlvR0VKusUM4HoSsO7ZbvcZ53YwR57+AKw=="], 222 | 223 | "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-0KpqsovgHcIzm7eAGzzEZsEs0/nPYXnRBv+aPq/GehpNQuE/NAQu+YgZXIIof+VflDFuyXOEnaFr7T5MZ1INhA=="], 224 | 225 | "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-B1mjeXNS26kBOHv5sXARf6Wd0PWHV9x1TDlW0ummrBUOUAxAy5wcy4Nii1wzNvCdvC448hgiL06ylhwAbNthmg=="], 226 | 227 | "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.1", "@tailwindcss/oxide": "4.1.1", "postcss": "^8.4.41", "tailwindcss": "4.1.1" } }, "sha512-GX9AEM+msH0i2Yh1b6CuDRaZRo3kmbvIrLbSfvJ53C3uaAgsQ//fTQAh9HMQ6t1a9zvoUptlYqG//plWsBQTCw=="], 228 | 229 | "@types/node": ["@types/node@20.17.30", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg=="], 230 | 231 | "@types/react": ["@types/react@19.1.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w=="], 232 | 233 | "@types/react-dom": ["@types/react-dom@19.1.1", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w=="], 234 | 235 | "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], 236 | 237 | "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], 238 | 239 | "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], 240 | 241 | "caniuse-lite": ["caniuse-lite@1.0.30001707", "", {}, "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw=="], 242 | 243 | "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], 244 | 245 | "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], 246 | 247 | "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], 248 | 249 | "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], 250 | 251 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 252 | 253 | "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 254 | 255 | "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], 256 | 257 | "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 258 | 259 | "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], 260 | 261 | "detect-libc": ["detect-libc@2.0.3", "", {}, "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw=="], 262 | 263 | "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], 264 | 265 | "enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="], 266 | 267 | "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], 268 | 269 | "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], 270 | 271 | "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], 272 | 273 | "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], 274 | 275 | "jiti": ["jiti@2.4.2", "", { "bin": "lib/jiti-cli.mjs" }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], 276 | 277 | "lightningcss": ["lightningcss@1.29.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.29.2", "lightningcss-darwin-x64": "1.29.2", "lightningcss-freebsd-x64": "1.29.2", "lightningcss-linux-arm-gnueabihf": "1.29.2", "lightningcss-linux-arm64-gnu": "1.29.2", "lightningcss-linux-arm64-musl": "1.29.2", "lightningcss-linux-x64-gnu": "1.29.2", "lightningcss-linux-x64-musl": "1.29.2", "lightningcss-win32-arm64-msvc": "1.29.2", "lightningcss-win32-x64-msvc": "1.29.2" } }, "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA=="], 278 | 279 | "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.29.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA=="], 280 | 281 | "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.29.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w=="], 282 | 283 | "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.29.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg=="], 284 | 285 | "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.29.2", "", { "os": "linux", "cpu": "arm" }, "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg=="], 286 | 287 | "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.29.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ=="], 288 | 289 | "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.29.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ=="], 290 | 291 | "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.29.2", "", { "os": "linux", "cpu": "x64" }, "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg=="], 292 | 293 | "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.29.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w=="], 294 | 295 | "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.29.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw=="], 296 | 297 | "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.29.2", "", { "os": "win32", "cpu": "x64" }, "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA=="], 298 | 299 | "lucide-react": ["lucide-react@0.487.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw=="], 300 | 301 | "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], 302 | 303 | "next": ["next@15.2.4", "", { "dependencies": { "@next/env": "15.2.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.2.4", "@next/swc-darwin-x64": "15.2.4", "@next/swc-linux-arm64-gnu": "15.2.4", "@next/swc-linux-arm64-musl": "15.2.4", "@next/swc-linux-x64-gnu": "15.2.4", "@next/swc-linux-x64-musl": "15.2.4", "@next/swc-win32-arm64-msvc": "15.2.4", "@next/swc-win32-x64-msvc": "15.2.4", "sharp": "^0.33.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": "dist/bin/next" }, "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ=="], 304 | 305 | "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], 306 | 307 | "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 308 | 309 | "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], 310 | 311 | "prisma": ["prisma@6.11.1", "", { "dependencies": { "@prisma/config": "6.11.1", "@prisma/engines": "6.11.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-VzJToRlV0s9Vu2bfqHiRJw73hZNCG/AyJeX+kopbu4GATTjTUdEWUteO3p4BLYoHpMS4o8pD3v6tF44BHNZI1w=="], 312 | 313 | "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], 314 | 315 | "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], 316 | 317 | "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], 318 | 319 | "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], 320 | 321 | "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], 322 | 323 | "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], 324 | 325 | "semver": ["semver@7.7.1", "", { "bin": "bin/semver.js" }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], 326 | 327 | "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], 328 | 329 | "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], 330 | 331 | "sonner": ["sonner@2.0.6", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q=="], 332 | 333 | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 334 | 335 | "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], 336 | 337 | "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], 338 | 339 | "tailwind-merge": ["tailwind-merge@3.1.0", "", {}, "sha512-aV27Oj8B7U/tAOMhJsSGdWqelfmudnGMdXIlMnk1JfsjwSjts6o8HyfN7SFH3EztzH4YH8kk6GbLTHzITJO39Q=="], 340 | 341 | "tailwindcss": ["tailwindcss@4.1.1", "", {}, "sha512-QNbdmeS979Efzim2g/bEvfuh+fTcIdp1y7gA+sb6OYSW74rt7Cr7M78AKdf6HqWT3d5AiTb7SwTT3sLQxr4/qw=="], 342 | 343 | "tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="], 344 | 345 | "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 346 | 347 | "tw-animate-css": ["tw-animate-css@1.2.5", "", {}, "sha512-ABzjfgVo+fDbhRREGL4KQZUqqdPgvc5zVrLyeW9/6mVqvaDepXc7EvedA+pYmMnIOsUAQMwcWzNvom26J2qYvQ=="], 348 | 349 | "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], 350 | 351 | "undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], 352 | 353 | "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], 354 | 355 | "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], 356 | 357 | "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], 358 | 359 | "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], 360 | 361 | "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.2", "", { "dependencies": { "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w=="], 362 | 363 | "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], 364 | 365 | "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="], 366 | 367 | "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="], 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/ActionButtons.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { RotateCw, X, Zap, Play, Trash2 } from "lucide-react"; 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipProvider, 7 | TooltipTrigger, 8 | } from "@/components/ui/tooltip"; 9 | 10 | interface ActionButtonsProps { 11 | containerId: string; 12 | handleAction: (id: string, action: string) => void; 13 | containerStatus: string; 14 | } 15 | 16 | const ActionButtons: React.FC = ({ 17 | containerId, 18 | handleAction, 19 | containerStatus, 20 | }) => { 21 | const runningActions = [ 22 | { type: "Restart", icon: }, 23 | { type: "Stop", icon: }, 24 | { type: "Kill", icon: }, 25 | ]; 26 | 27 | const stoppedActions = [ 28 | { type: "Start", icon: }, 29 | { type: "Delete", icon: }, 30 | ]; 31 | 32 | const actions = 33 | containerStatus === "running" ? runningActions : stoppedActions; 34 | 35 | return ( 36 |
37 | {actions.map(({ type, icon }) => ( 38 | 39 | 40 | 41 | 50 | 51 | 52 |

{type}

53 |
54 |
55 |
56 | ))} 57 |
58 | ); 59 | }; 60 | 61 | export default ActionButtons; 62 | -------------------------------------------------------------------------------- /components/AutoRefresh.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useRouter } from "next/navigation"; 3 | import { useEffect, useState } from "react"; 4 | import { Switch } from "@/components/ui/switch"; 5 | 6 | export function AutoRefresh({ interval = 10000 }: { interval?: number }) { 7 | const router = useRouter(); 8 | const [enabled, setEnabled] = useState(true); 9 | const [checkedCookie, setCheckedCookie] = useState(false); 10 | 11 | // Load initial state from cookie 12 | useEffect(() => { 13 | const cookie = document.cookie 14 | .split("; ") 15 | .find((row) => row.startsWith("sid_autorefresh=")); 16 | if (cookie) { 17 | setEnabled(cookie.split("=")[1] === "true"); 18 | } 19 | setCheckedCookie(true); 20 | }, []); 21 | 22 | // Store state in cookie when changed 23 | useEffect(() => { 24 | document.cookie = `sid_autorefresh=${enabled}; path=/; max-age=31536000`; 25 | }, [enabled]); 26 | 27 | useEffect(() => { 28 | if (!enabled) return; 29 | const intervalId = setInterval(() => { 30 | router.refresh(); 31 | }, interval); 32 | return () => clearInterval(intervalId); 33 | }, [router, interval, enabled]); 34 | 35 | if (!checkedCookie) return null; 36 | 37 | return ( 38 |
39 | 44 | 47 |
48 | ); 49 | } -------------------------------------------------------------------------------- /components/ContainerDashboard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState } from "react"; 3 | import { Badge } from "@/components/ui/badge"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import { 12 | Table, 13 | TableBody, 14 | TableCell, 15 | TableHead, 16 | TableHeader, 17 | TableRow, 18 | } from "@/components/ui/table"; 19 | import { Input } from "@/components/ui/input"; 20 | import { 21 | Tooltip, 22 | TooltipContent, 23 | TooltipTrigger, 24 | } from "@/components/ui/tooltip"; 25 | import { Loader2, RefreshCw, Search } from "lucide-react"; 26 | import ActionButtons from "./ActionButtons"; 27 | import { 28 | restartContainer, 29 | stopContainer, 30 | killContainer, 31 | deleteContainer, 32 | } from "@/lib/process"; 33 | import { toast } from "sonner"; 34 | import { Button } from "./ui/button"; 35 | import { refresh } from "@/lib/db"; 36 | import { 37 | AlertDialog, 38 | AlertDialogAction, 39 | AlertDialogCancel, 40 | AlertDialogContent, 41 | AlertDialogDescription, 42 | AlertDialogFooter, 43 | AlertDialogHeader, 44 | AlertDialogTitle, 45 | } from "@/components/ui/alert-dialog"; 46 | 47 | interface Container { 48 | ID: string; 49 | Names: string; 50 | Image: string; 51 | State: string; 52 | CreatedAt: string; 53 | Ports: string; 54 | Mounts: string; 55 | } 56 | 57 | const ContainerDashboard: React.FC = ({ containers }) => { 58 | const [searchTerm, setSearchTerm] = useState(""); 59 | const [confirmOpen, setConfirmOpen] = useState(false); 60 | const [deleteTarget, setDeleteTarget] = useState(""); 61 | const [loadingId, setLoadingId] = useState(null); 62 | 63 | const filteredContainers = containers.filter( 64 | (container: Container) => 65 | container.Names.toLowerCase().includes(searchTerm.toLowerCase()) || 66 | container.Image.toLowerCase().includes(searchTerm.toLowerCase()) || 67 | container.ID.toLowerCase().includes(searchTerm.toLowerCase()), 68 | ); 69 | 70 | async function handleAction(id: string, action: string) { 71 | setLoadingId(id); 72 | try { 73 | if (action === "Stop") { 74 | const response: any = await stopContainer(id); 75 | if (response.status === "error") { 76 | toast.error(`Failed to stop container ${id}`); 77 | } else if (response.status === "success") { 78 | toast.success(`Container ${id} stopped successfully!`); 79 | } 80 | } else if (action === "Restart") { 81 | const response: any = await restartContainer(id); 82 | if (response.status === "error") { 83 | toast.error(`Failed to restart container ${id}`); 84 | } else if (response.status === "success") { 85 | toast.success(`Container ${id} restarted successfully!`); 86 | } 87 | } else if (action === "Kill") { 88 | const response: any = await killContainer(id); 89 | if (response.status === "error") { 90 | toast.error(`Failed to kill container ${id}`); 91 | } else if (response.status === "success") { 92 | toast.success(`Container ${id} killed successfully!`); 93 | } 94 | } else if (action === "Start") { 95 | const response: any = await restartContainer(id); 96 | if (response.status === "error") { 97 | toast.error(`Failed to start container ${id}`); 98 | } else if (response.status === "success") { 99 | toast.success(`Container ${id} started successfully!`); 100 | } 101 | } else if (action === "Delete") { 102 | setConfirmOpen(true); 103 | setDeleteTarget(id); 104 | } 105 | } finally { 106 | setLoadingId(null); 107 | } 108 | } 109 | 110 | async function handleDelete() { 111 | setLoadingId(deleteTarget); 112 | setConfirmOpen(false); 113 | console.log(deleteTarget); 114 | const response: any = await deleteContainer(deleteTarget); 115 | if (response.status === "error") { 116 | toast.error(`Failed to delete container ${deleteTarget}`); 117 | } else if (response.status === "success") { 118 | toast.success(`Container ${deleteTarget} deleted successfully!`); 119 | } 120 | setLoadingId(""); 121 | setLoadingId(null); 122 | } 123 | 124 | return ( 125 | 126 | 127 |
128 | Docker Containers 129 | Manage your running containers 130 |
131 | 135 |
136 | 137 | 138 | 139 | Confirm deletion 140 | 141 | Are you sure you want to delete the container? This will also 142 | delete non-persistent volumes 143 | 144 | 145 | 146 | setConfirmOpen(false)}> 147 | Cancel 148 | 149 | handleDelete()}> 150 | Confirm 151 | 152 | 153 | 154 | 155 | 156 |
157 | 158 | setSearchTerm(e.target.value)} 163 | /> 164 |
165 |
166 | {!containers || containers.length === 0 ? ( 167 |
168 | {searchTerm 169 | ? "No containers match your search." 170 | : "No containers found. They may still be loading or there are no Docker containers."} 171 |
172 | ) : ( 173 | 174 | 175 | 176 | Status 177 | Name 178 | Image 179 | ID 180 | 181 | Created 182 | 183 | Ports 184 | Mounts 185 | Actions 186 | 187 | 188 | 189 | {filteredContainers.length === 0 ? ( 190 | 191 | 195 | No containers match your search. 196 | 197 | 198 | ) : ( 199 | filteredContainers.map((container: Container) => ( 200 | 201 | 202 | 214 | {container.State} 215 | 216 | 217 | 218 | {container.Names} 219 | 220 | {container.Image} 221 | 222 | {container.ID.substring(0, 10)} 223 | 224 | 225 | 226 | 227 | {container.CreatedAt.split(" ")[0]} 228 | 229 | 230 | {container.CreatedAt} 231 | 232 | 233 | 234 | 235 | {container.Ports} 236 | 237 | 238 | {container.Ports && container.Ports.trim() !== "" && ( 239 | 240 |

241 | {container.Ports} 242 |

243 |
244 | )} 245 |
246 | 247 | 248 | 249 | {container.Mounts} 250 | 251 | 252 | {container.Mounts && container.Mounts.trim() !== "" && ( 253 | 254 |

255 | {container.Mounts} 256 |

257 |
258 | )} 259 |
260 | 261 | {loadingId === container.ID ? ( 262 | 263 | 264 | 265 | ) : ( 266 | 271 | )} 272 | 273 |
274 | )) 275 | )} 276 |
277 |
278 | )} 279 |
280 |
281 |
282 | ); 283 | }; 284 | 285 | export default ContainerDashboard; 286 | -------------------------------------------------------------------------------- /components/EventTable.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState } from "react"; 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardHeader, 8 | CardTitle, 9 | } from "@/components/ui/card"; 10 | import { 11 | Tooltip, 12 | TooltipContent, 13 | TooltipProvider, 14 | TooltipTrigger, 15 | } from "@/components/ui/tooltip"; 16 | import { 17 | Table, 18 | TableBody, 19 | TableCell, 20 | TableHead, 21 | TableHeader, 22 | TableRow, 23 | } from "@/components/ui/table"; 24 | import { Input } from "@/components/ui/input"; 25 | import { Button } from "@/components/ui/button"; 26 | import { 27 | Search, 28 | Info, 29 | CheckCircle, 30 | AlertTriangle, 31 | ChevronLeft, 32 | ChevronRight, 33 | RefreshCw, 34 | } from "lucide-react"; 35 | import { formatDistanceToNow, formatISO } from "date-fns"; 36 | import { usePathname, useSearchParams } from "next/navigation"; 37 | import { useRouter } from "next/navigation"; 38 | import { refresh } from "@/lib/db"; 39 | 40 | const typeIcon = (type: string) => { 41 | switch (type.toLowerCase()) { 42 | case "error": 43 | return ; 44 | case "success": 45 | return ; 46 | case "info": 47 | default: 48 | return ; 49 | } 50 | }; 51 | 52 | interface EventTableProps { 53 | events: any[]; 54 | page: number; 55 | total: number; 56 | pageSize?: number; 57 | onPageChange?: (page: number) => void; 58 | } 59 | 60 | const EventTable: React.FC = ({ 61 | events, 62 | page, 63 | total, 64 | pageSize = 10, 65 | }) => { 66 | const [searchTerm, setSearchTerm] = useState(""); 67 | const pathname = usePathname(); 68 | const searchParams = useSearchParams(); 69 | const router = useRouter(); 70 | const currentPage = Number(searchParams.get("page")) || 1; 71 | const filteredEvents = events.filter( 72 | (event) => 73 | event.type.toLowerCase().includes(searchTerm.toLowerCase()) || 74 | event.message.toLowerCase().includes(searchTerm.toLowerCase()) || 75 | (event.stack?.name?.toLowerCase() || "").includes( 76 | searchTerm.toLowerCase(), 77 | ), 78 | ); 79 | 80 | const createPageURL = (pageNumber: number | string) => { 81 | console.log("Creating page URL for page:", pageNumber); 82 | const params = new URLSearchParams(searchParams); 83 | params.set("page", pageNumber.toString()); 84 | return `${pathname}?${params.toString()}`; 85 | }; 86 | 87 | const totalPages = Math.max(1, Math.ceil(total / pageSize)); 88 | const displayEvents = searchTerm ? filteredEvents.slice(0, pageSize) : events; 89 | 90 | const formatDate = (dateString: string) => { 91 | try { 92 | return formatDistanceToNow(new Date(dateString), { addSuffix: true }); 93 | } catch { 94 | return "Invalid date"; 95 | } 96 | }; 97 | 98 | const handlePrev = () => { 99 | let url = createPageURL(Math.max(1, page - 1)); 100 | url = url + "#events"; 101 | router.push(url); 102 | }; 103 | const handleNext = () => { 104 | let url = createPageURL(Math.min(totalPages, page + 1)); 105 | url = url + "#events"; 106 | router.push(url); 107 | }; 108 | 109 | // Reset to first page on search 110 | React.useEffect(() => { 111 | createPageURL(1); 112 | }, [searchTerm]); 113 | 114 | React.useEffect(() => { 115 | const hash = window.location.hash.substring(1); 116 | if (hash) { 117 | const element = document.getElementById(hash); 118 | element?.scrollIntoView({ behavior: "smooth" }); 119 | } 120 | }, []); 121 | 122 | return ( 123 | 124 | 125 |
126 | Events 127 | Recent system events and logs 128 |
129 | 133 |
134 | 135 |
136 | 137 | setSearchTerm(e.target.value)} 142 | /> 143 |
144 |
145 | {displayEvents.length === 0 ? ( 146 |
147 | No events found. 148 |
149 | ) : ( 150 | <> 151 | 152 | 153 | 154 | Type 155 | Message 156 | 157 | Stack 158 | 159 | Timestamp 160 | 161 | 162 | 163 | {displayEvents.map((event) => ( 164 | 165 | 166 | {typeIcon(event.type)} 167 | {event.type} 168 | 169 | 170 | 171 | 172 | 173 | {event.message} 174 | 175 | 176 | 177 |

178 | {event.message} 179 |

180 |
181 |
182 |
183 | 184 | {event.stack?.name || ( 185 | None 186 | )} 187 | 188 | 189 | 190 | 191 | {formatDate(event.createdAt)} 192 | 193 | 194 | 195 | {formatISO(event.createdAt)} 196 | 197 | 198 |
199 | ))} 200 |
201 |
202 |
203 | 204 | Page {page} of {totalPages} 205 | 206 |
207 | 216 | 225 |
226 |
227 | 228 | )} 229 |
230 |
231 |
232 | ); 233 | }; 234 | 235 | export default EventTable; 236 | -------------------------------------------------------------------------------- /components/StackTable.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState } from "react"; 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardHeader, 8 | CardTitle, 9 | } from "@/components/ui/card"; 10 | import { 11 | Table, 12 | TableBody, 13 | TableCell, 14 | TableHead, 15 | TableHeader, 16 | TableRow, 17 | } from "@/components/ui/table"; 18 | import { Input } from "@/components/ui/input"; 19 | import { 20 | Search, 21 | FileBox, 22 | FolderTree, 23 | FolderSync, 24 | AlertTriangle, 25 | CheckCircle, 26 | Info, 27 | TrendingUp, 28 | Loader2, 29 | } from "lucide-react"; 30 | import { Button } from "@/components/ui/button"; 31 | import { formatDistanceToNow, formatISO } from "date-fns"; 32 | import { syncStacks } from "@/lib/db"; 33 | import { toast } from "sonner"; 34 | import { clone, runDockerComposeForPath } from "@/lib/process"; 35 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; 36 | 37 | const StackList = ({ stacks }: any) => { 38 | const [searchTerm, setSearchTerm] = useState(""); 39 | const [loadingId, setLoadingId] = useState(null); 40 | const filteredStacks = stacks.filter((stack: { name: string }) => 41 | stack.name?.toLowerCase().includes(searchTerm.toLowerCase()), 42 | ); 43 | 44 | async function handleSync() { 45 | try { 46 | await clone(); 47 | await syncStacks(); 48 | toast.success("Stacks synced successfully!"); 49 | } catch (error) { 50 | console.error("Error syncing stacks:", error); 51 | toast.error("Failed to sync stacks. Please try again."); 52 | } 53 | } 54 | 55 | async function handleUp(stack: any) { 56 | toast.info(`Deploying stack. This may take several minutes...`); 57 | await clone(); 58 | await syncStacks(); 59 | setLoadingId(stack.id); 60 | try { 61 | await runDockerComposeForPath( 62 | getShortPath(stack.path).split("/").slice(0, 3).join("/"), 63 | ); 64 | toast.success("Stack deployed successfully!"); 65 | setLoadingId(null); 66 | } catch (error) { 67 | toast.error("Failed to deploy stack. Please try again."); 68 | } 69 | } 70 | 71 | const typeIcon = (type: string) => { 72 | switch (type.toLowerCase()) { 73 | case "error": 74 | return ; 75 | case "success": 76 | return ; 77 | case "info": 78 | default: 79 | return ; 80 | } 81 | }; 82 | 83 | const formatDate = (dateString: string) => { 84 | try { 85 | if (dateString) { 86 | return formatDistanceToNow(new Date(dateString), { addSuffix: true }); 87 | } else { 88 | return "Not yet deployed"; 89 | } 90 | } catch { 91 | return "Invalid date"; 92 | } 93 | }; 94 | 95 | const getShortPath = (fullPath: string) => { 96 | // Try to find the "/compose-v2" or repo folder in the path and return from there 97 | const repoRoot = 98 | process.env.REPO_URL?.split("/").pop()?.replace(".git", "") || 99 | "compose-v2"; 100 | const idx = fullPath.indexOf(`/${repoRoot}/`); 101 | if (idx !== -1) { 102 | return fullPath.substring(idx); 103 | } 104 | // Fallback: show last two segments 105 | const parts = fullPath.split("/"); 106 | return "/" + parts.slice(-3).join("/"); 107 | }; 108 | 109 | // Empty state component 110 | const EmptyState = () => ( 111 |
112 |
113 | 114 |
115 |

No stacks found

116 |

117 | Get started by importing your schema from GitHub 118 |

119 | 123 |
124 | ); 125 | 126 | return ( 127 | 128 | 129 |
130 | Stacks & Schema 131 | 132 | Manage your application stacks and deployment schema 133 | 134 |
135 | 139 |
140 | 141 |
142 | 143 | setSearchTerm(e.target.value)} 148 | /> 149 |
150 | 151 | {filteredStacks.length === 0 && searchTerm === "" ? ( 152 | 153 | ) : filteredStacks.length === 0 ? ( 154 |
155 | No stacks match your search 156 |
157 | ) : ( 158 |
159 | 160 | 161 | 162 | Name 163 | Last Status 164 | 165 | Schema Path 166 | 167 | 168 | Created 169 | 170 | 171 | Last Synced 172 | 173 | 174 | Last Deployed 175 | 176 | Actions 177 | 178 | 179 | 180 | {filteredStacks.map((stack: any) => ( 181 | 182 | 183 |
184 | 185 | {stack.name} 186 |
187 |
188 | 189 | {stack.events && stack.events.length > 0 ? ( 190 | 191 | {typeIcon(stack.events[0].type)} 192 | {stack.events[0].type} 193 | 194 | ) : ( 195 | "No events" 196 | )} 197 | 198 | 199 | 202 | {typeof stack.path === "string" 203 | ? getShortPath(stack.path) 204 | : stack.path} 205 | 206 | 207 | 208 | 209 | 210 | {formatDate(stack.createdAt)} 211 | 212 | 213 | 214 | {formatISO(stack.createdAt)} 215 | 216 | 217 | 218 | 219 | 220 | {formatDate(stack.updatedAt)} 221 | 222 | 223 | 224 | {formatISO(stack.updatedAt)} 225 | 226 | 227 | 228 | 229 | 230 | {formatDate(stack.events[0]?.createdAt)} 231 | 232 | 233 | {stack.events && stack.events[0]?.createdAt && ( 234 | 235 | {formatISO(stack.events[0].createdAt)} 236 | 237 | )} 238 | 239 | 240 | {loadingId === stack.id ? ( 241 | 242 | 243 | 244 | ) : ( 245 | 246 | 247 | 255 | 256 | 257 |

258 | Manually

Bring Up 259 |

260 |
261 |
262 | )} 263 |
264 |
265 | ))} 266 |
267 |
268 |
269 | )} 270 |
271 |
272 | ); 273 | }; 274 | 275 | export default StackList; 276 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children}; 11 | } 12 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | function AlertDialog({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function AlertDialogTrigger({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | function AlertDialogPortal({ 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | ) 29 | } 30 | 31 | function AlertDialogOverlay({ 32 | className, 33 | ...props 34 | }: React.ComponentProps) { 35 | return ( 36 | 44 | ) 45 | } 46 | 47 | function AlertDialogContent({ 48 | className, 49 | ...props 50 | }: React.ComponentProps) { 51 | return ( 52 | 53 | 54 | 62 | 63 | ) 64 | } 65 | 66 | function AlertDialogHeader({ 67 | className, 68 | ...props 69 | }: React.ComponentProps<"div">) { 70 | return ( 71 |
76 | ) 77 | } 78 | 79 | function AlertDialogFooter({ 80 | className, 81 | ...props 82 | }: React.ComponentProps<"div">) { 83 | return ( 84 |
92 | ) 93 | } 94 | 95 | function AlertDialogTitle({ 96 | className, 97 | ...props 98 | }: React.ComponentProps) { 99 | return ( 100 | 105 | ) 106 | } 107 | 108 | function AlertDialogDescription({ 109 | className, 110 | ...props 111 | }: React.ComponentProps) { 112 | return ( 113 | 118 | ) 119 | } 120 | 121 | function AlertDialogAction({ 122 | className, 123 | ...props 124 | }: React.ComponentProps) { 125 | return ( 126 | 130 | ) 131 | } 132 | 133 | function AlertDialogCancel({ 134 | className, 135 | ...props 136 | }: React.ComponentProps) { 137 | return ( 138 | 142 | ) 143 | } 144 | 145 | export { 146 | AlertDialog, 147 | AlertDialogPortal, 148 | AlertDialogOverlay, 149 | AlertDialogTrigger, 150 | AlertDialogContent, 151 | AlertDialogHeader, 152 | AlertDialogFooter, 153 | AlertDialogTitle, 154 | AlertDialogDescription, 155 | AlertDialogAction, 156 | AlertDialogCancel, 157 | } 158 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-card text-card-foreground", 12 | destructive: 13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | function Alert({ 23 | className, 24 | variant, 25 | ...props 26 | }: React.ComponentProps<"div"> & VariantProps) { 27 | return ( 28 |
34 | ) 35 | } 36 | 37 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { 38 | return ( 39 |
47 | ) 48 | } 49 | 50 | function AlertDescription({ 51 | className, 52 | ...props 53 | }: React.ComponentProps<"div">) { 54 | return ( 55 |
63 | ) 64 | } 65 | 66 | export { Alert, AlertTitle, AlertDescription } 67 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Drawer as DrawerPrimitive } from "vaul" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Drawer({ 9 | ...props 10 | }: React.ComponentProps) { 11 | return 12 | } 13 | 14 | function DrawerTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return 18 | } 19 | 20 | function DrawerPortal({ 21 | ...props 22 | }: React.ComponentProps) { 23 | return 24 | } 25 | 26 | function DrawerClose({ 27 | ...props 28 | }: React.ComponentProps) { 29 | return 30 | } 31 | 32 | function DrawerOverlay({ 33 | className, 34 | ...props 35 | }: React.ComponentProps) { 36 | return ( 37 | 45 | ) 46 | } 47 | 48 | function DrawerContent({ 49 | className, 50 | children, 51 | ...props 52 | }: React.ComponentProps) { 53 | return ( 54 | 55 | 56 | 68 |
69 | {children} 70 | 71 | 72 | ) 73 | } 74 | 75 | function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { 76 | return ( 77 |
82 | ) 83 | } 84 | 85 | function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { 86 | return ( 87 |
92 | ) 93 | } 94 | 95 | function DrawerTitle({ 96 | className, 97 | ...props 98 | }: React.ComponentProps) { 99 | return ( 100 | 105 | ) 106 | } 107 | 108 | function DrawerDescription({ 109 | className, 110 | ...props 111 | }: React.ComponentProps) { 112 | return ( 113 | 118 | ) 119 | } 120 | 121 | export { 122 | Drawer, 123 | DrawerPortal, 124 | DrawerOverlay, 125 | DrawerTrigger, 126 | DrawerClose, 127 | DrawerContent, 128 | DrawerHeader, 129 | DrawerFooter, 130 | DrawerTitle, 131 | DrawerDescription, 132 | } 133 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner, ToasterProps } from "sonner" 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 22 | ) 23 | } 24 | 25 | export { Toaster } 26 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitive from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Switch({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | 27 | 28 | ) 29 | } 30 | 31 | export { Switch } 32 | -------------------------------------------------------------------------------- /components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Table({ className, ...props }: React.ComponentProps<"table">) { 8 | return ( 9 |
13 | 18 | 19 | ) 20 | } 21 | 22 | function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { 23 | return ( 24 | 29 | ) 30 | } 31 | 32 | function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { 33 | return ( 34 | 39 | ) 40 | } 41 | 42 | function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { 43 | return ( 44 | tr]:last:border-b-0", 48 | className 49 | )} 50 | {...props} 51 | /> 52 | ) 53 | } 54 | 55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) { 56 | return ( 57 | 65 | ) 66 | } 67 | 68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) { 69 | return ( 70 |
[role=checkbox]]:translate-y-[2px]", 74 | className 75 | )} 76 | {...props} 77 | /> 78 | ) 79 | } 80 | 81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) { 82 | return ( 83 | [role=checkbox]]:translate-y-[2px]", 87 | className 88 | )} 89 | {...props} 90 | /> 91 | ) 92 | } 93 | 94 | function TableCaption({ 95 | className, 96 | ...props 97 | }: React.ComponentProps<"caption">) { 98 | return ( 99 |
104 | ) 105 | } 106 | 107 | export { 108 | Table, 109 | TableHeader, 110 | TableBody, 111 | TableFooter, 112 | TableHead, 113 | TableRow, 114 | TableCell, 115 | TableCaption, 116 | } 117 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function TooltipProvider({ 9 | delayDuration = 0, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function Tooltip({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | function TooltipTrigger({ 32 | ...props 33 | }: React.ComponentProps) { 34 | return 35 | } 36 | 37 | function TooltipContent({ 38 | className, 39 | sideOffset = 0, 40 | children, 41 | ...props 42 | }: React.ComponentProps) { 43 | return ( 44 | 45 | 54 | {children} 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 62 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: ghcr.io/declan-wade/sid:latest 4 | ports: 5 | - "3000:3000" 6 | environment: 7 | - SID_ALLOWED_HOSTS=localhost:3000 ## Required: the host and port where the app is accessible 8 | - REPO_URL=https://@github.com// ## Required: the URL of the repository root 9 | - REPO_NAME=compose-v2 ## Required: the name of the repository 10 | - WORKING_DIR=/home/user/sid/data ## Required if your containers use relative volume bindings 11 | - DB_URL=postgresql://admin:password@db:5432/sid ## Required: the database URL 12 | - GITHUB_WEBHOOK_SECRET="abc" ## This is used to verify the GitHub webhook 13 | volumes: 14 | - ./sid/app/data:/app/data 15 | - /var/run/docker.sock:/var/run/docker.sock 16 | db: 17 | image: postgres 18 | restart: always 19 | volumes: 20 | - ./sid/app/db:/var/lib/postgresql/data 21 | environment: 22 | POSTGRES_USER: admin 23 | POSTGRES_PASSWORD: password 24 | POSTGRES_DB: sid 25 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { PrismaClient } from "@prisma/client"; 4 | import { findAllDockerComposeFiles } from "./process"; 5 | import { revalidatePath } from "next/cache"; 6 | 7 | const prisma = new PrismaClient(); 8 | 9 | export async function createStack(formData: any) { 10 | try { 11 | console.info( 12 | `[createStack] Creating stack: ${formData.name} at ${formData.filePath}`, 13 | ); 14 | const response = await prisma.stack.create({ 15 | data: { 16 | name: formData.name, 17 | status: "created", 18 | path: formData.filePath, 19 | }, 20 | }); 21 | console.info(`[createStack] Stack created: ${formData.name}`); 22 | createEvent("Success", `Stack created: ${formData.name}`); 23 | revalidatePath("/"); 24 | return response; 25 | } catch (err: any) { 26 | console.error(`[createStack] Error creating stack: ${err.message}`); 27 | throw err; 28 | } 29 | } 30 | 31 | export async function syncStacks() { 32 | console.info("[syncStacks] Finding all docker-compose files..."); 33 | const composeFiles = await findAllDockerComposeFiles(); 34 | if (!composeFiles || composeFiles.length === 0) { 35 | console.info("[syncStacks] No docker-compose files found."); 36 | return []; 37 | } 38 | 39 | const stacksCreated: any[] = []; 40 | for (const filePath of composeFiles) { 41 | const parts = filePath.split("/"); 42 | const stackName = parts[parts.length - 2]; 43 | try { 44 | console.info(`[syncStacks] Upserting stack: ${stackName} (${filePath})`); 45 | const stack = await prisma.stack.upsert({ 46 | where: { name: stackName }, 47 | update: { 48 | path: filePath, 49 | status: "synced", 50 | updatedAt: new Date(), 51 | }, 52 | create: { 53 | name: stackName, 54 | path: filePath, 55 | status: "synced", 56 | }, 57 | }); 58 | stacksCreated.push(stack); 59 | console.info(`[syncStacks] Stack synced: ${stackName}`); 60 | await createEvent("Info", `Stack synced: ${stackName}`); 61 | } catch (err: any) { 62 | console.error( 63 | `[syncStacks] Failed to sync stack for ${filePath}: ${err.message}`, 64 | ); 65 | await createEvent( 66 | "Error", 67 | `Failed to sync stack for ${filePath}: ${err.message}`, 68 | ); 69 | } 70 | } 71 | console.info( 72 | `[syncStacks] Finished syncing stacks. Total: ${stacksCreated.length}`, 73 | ); 74 | revalidatePath("/"); 75 | return stacksCreated; 76 | } 77 | 78 | export async function getStacks() { 79 | try { 80 | console.info("[getStacks] Fetching all stacks with latest event..."); 81 | const response = await prisma.stack.findMany({ 82 | include: { 83 | events: { 84 | orderBy: { createdAt: "desc" }, 85 | take: 1, 86 | }, 87 | }, 88 | }); 89 | console.info(`[getStacks] Found ${response.length} stacks.`); 90 | return response; 91 | } catch (err: any) { 92 | console.error(`[getStacks] Error fetching stacks: ${err.message}`); 93 | throw err; 94 | } 95 | } 96 | 97 | export async function createEvent( 98 | type: string, 99 | message: any, 100 | stackName?: string, 101 | ) { 102 | try { 103 | console.info( 104 | `[createEvent] Creating event: type=${type}, stackName=${stackName}, message=${typeof message === "string" ? message : JSON.stringify(message)}`, 105 | ); 106 | const response = await prisma.event.create({ 107 | data: { 108 | type: type, 109 | message: message, 110 | stack: { 111 | connect: stackName ? { name: stackName } : undefined, 112 | }, 113 | }, 114 | }); 115 | console.info(`[createEvent] Event created: ${response.id}`); 116 | return response; 117 | } catch (err: any) { 118 | console.error(`[createEvent] Error creating event: ${err.message}`); 119 | throw err; 120 | } 121 | } 122 | 123 | export async function getEvents(page: number, pageSize: number) { 124 | try { 125 | const skip = (page - 1) * pageSize; 126 | console.info( 127 | `[getEvents] Fetching events: page=${page}, pageSize=${pageSize}, skip=${skip}`, 128 | ); 129 | const [events, total] = await Promise.all([ 130 | prisma.event.findMany({ 131 | orderBy: { createdAt: "desc" }, 132 | skip, 133 | take: pageSize, 134 | include: { stack: true }, 135 | }), 136 | prisma.event.count(), 137 | ]); 138 | console.info( 139 | `[getEvents] Fetched ${events.length} events (total: ${total})`, 140 | ); 141 | return { events, total }; 142 | } catch (err: any) { 143 | console.error(`[getEvents] Error fetching events: ${err.message}`); 144 | throw err; 145 | } 146 | } 147 | 148 | export async function refresh(){ 149 | revalidatePath("/"); 150 | } -------------------------------------------------------------------------------- /lib/process.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { spawn } from "node:child_process"; 4 | import { readdirSync, statSync } from "fs"; 5 | import { join } from "path"; 6 | import { createEvent } from "./db"; 7 | import { revalidatePath } from "next/cache"; 8 | 9 | // Helper function for consistent error handling 10 | function handleProcessError( 11 | functionName: string, 12 | id: string | null, 13 | error: Error, 14 | reject: (reason?: any) => void, 15 | ) { 16 | const message = `${functionName}${id ? `(${id})` : ""} process error: ${error.message}`; 17 | console.error(message); 18 | createEvent("Error", `Process error: ${error.message}`); 19 | reject(new Error(`Process error: ${error.message}`)); 20 | } 21 | 22 | function handleProcessClose( 23 | functionName: string, 24 | id: string | null, 25 | code: number, 26 | errorChunks: Buffer[], 27 | reject: (reason?: any) => void, 28 | ): boolean { 29 | if (code !== 0) { 30 | const errorMessage = Buffer.concat(errorChunks).toString().trim(); 31 | const message = `${functionName}${id ? `(${id})` : ""} process exited with code ${code}: ${errorMessage || "Unknown error"}`; 32 | console.error(message); 33 | createEvent( 34 | "Error", 35 | `Process exited with code ${code}: ${errorMessage || "Unknown error"}`, 36 | ); 37 | reject( 38 | new Error( 39 | `Process exited with code ${code}: ${errorMessage || "Unknown error"}`, 40 | ), 41 | ); 42 | return false; 43 | } 44 | return true; 45 | } 46 | 47 | function handleStderr( 48 | functionName: string, 49 | id: string | null, 50 | data: Buffer, 51 | operation: string, 52 | ) { 53 | console.error(`${functionName}${id ? `(${id})` : ""} stderr: ${data}`); 54 | createEvent("Error", `${operation} stderr: ${data}`); 55 | } 56 | 57 | export async function check() { 58 | return new Promise((resolve, reject) => { 59 | const ls = spawn("docker", [ 60 | "container", 61 | "ls", 62 | "-a", 63 | "--format={{json .}}", 64 | ]); 65 | let dataChunks: Buffer[] = []; 66 | let errorChunks: Buffer[] = []; 67 | 68 | ls.stdout.on("data", (data) => { 69 | dataChunks.push(data); 70 | }); 71 | 72 | ls.stderr.on("data", (data) => { 73 | errorChunks.push(data); 74 | handleStderr("check", null, data, "Check"); 75 | }); 76 | 77 | ls.on("error", (error) => { 78 | handleProcessError("check", null, error, reject); 79 | }); 80 | 81 | ls.on("close", (code) => { 82 | if (!handleProcessClose("check", null, code ?? 1, errorChunks, reject)) { 83 | return; 84 | } 85 | 86 | try { 87 | const rawOutput = Buffer.concat(dataChunks).toString().trim(); 88 | const jsonLines = rawOutput 89 | .split("\n") 90 | .filter((line) => line.trim() !== ""); 91 | 92 | if (jsonLines.length === 0) { 93 | console.info("check() found no containers."); 94 | resolve({ 95 | status: "success", 96 | containers: [], 97 | message: "No containers found", 98 | }); 99 | return; 100 | } 101 | 102 | const containers = jsonLines.map((line) => JSON.parse(line)); 103 | console.info(`check() found ${containers.length} containers.`); 104 | resolve({ status: "success", containers }); 105 | } catch (err) { 106 | console.error(`check() error parsing JSON: ${(err as Error).message}`); 107 | createEvent("Error", `Error parsing JSON: ${(err as Error).message}`); 108 | if (process.env.NTFY_URL) { 109 | fetch(`${process.env.NTFY_URL}`, { 110 | method: "POST", // PUT works too 111 | body: "Error parsing JSON: ${(err as Error).message}", 112 | headers: { 113 | "X-Title": "~~SID~~ - Error", 114 | }, 115 | }); 116 | } 117 | reject(new Error(`Error parsing JSON: ${(err as Error).message}`)); 118 | } 119 | }); 120 | }); 121 | } 122 | 123 | export async function stopContainer(id: string) { 124 | console.info(`Stopping container with ID: ${id}`); 125 | return new Promise((resolve, reject) => { 126 | const ls = spawn("docker", ["stop", id]); 127 | let dataChunks: Buffer[] = []; 128 | let errorChunks: Buffer[] = []; 129 | 130 | ls.stdout.on("data", (data) => { 131 | dataChunks.push(data); 132 | }); 133 | 134 | ls.stderr.on("data", (data) => { 135 | errorChunks.push(data); 136 | handleStderr("stopContainer", id, data, "Stop"); 137 | }); 138 | 139 | ls.on("error", (error) => { 140 | handleProcessError("stopContainer", id, error, reject); 141 | }); 142 | 143 | ls.on("close", (code) => { 144 | if ( 145 | !handleProcessClose("stopContainer", id, code ?? 1, errorChunks, reject) 146 | ) { 147 | return; 148 | } 149 | 150 | try { 151 | const rawOutput = Buffer.concat(dataChunks).toString().trim(); 152 | console.info(`stopContainer(${id}) success: ${rawOutput}`); 153 | createEvent("Success", `Container ${id} stopped successfully`); 154 | revalidatePath("/"); 155 | resolve({ status: "success", output: rawOutput }); 156 | } catch (err) { 157 | console.error( 158 | `stopContainer(${id}) error processing output: ${(err as Error).message}`, 159 | ); 160 | createEvent( 161 | "Error", 162 | `Error processing output: ${(err as Error).message}`, 163 | ); 164 | revalidatePath("/"); 165 | reject(new Error(`Error processing output: ${(err as Error).message}`)); 166 | } 167 | }); 168 | }); 169 | } 170 | 171 | export async function killContainer(id: string) { 172 | console.info(`Killing container with ID: ${id}`); 173 | return new Promise((resolve, reject) => { 174 | const ls = spawn("docker", ["kill", id]); 175 | let dataChunks: Buffer[] = []; 176 | let errorChunks: Buffer[] = []; 177 | 178 | ls.stdout.on("data", (data) => { 179 | dataChunks.push(data); 180 | }); 181 | 182 | ls.stderr.on("data", (data) => { 183 | errorChunks.push(data); 184 | handleStderr("killContainer", id, data, "Kill"); 185 | }); 186 | 187 | ls.on("error", (error) => { 188 | handleProcessError("killContainer", id, error, reject); 189 | }); 190 | 191 | ls.on("close", (code) => { 192 | if ( 193 | !handleProcessClose("killContainer", id, code ?? 1, errorChunks, reject) 194 | ) { 195 | return; 196 | } 197 | 198 | try { 199 | const rawOutput = Buffer.concat(dataChunks).toString().trim(); 200 | console.info(`killContainer(${id}) success: ${rawOutput}`); 201 | createEvent("Success", `Container ${id} killed successfully`); 202 | revalidatePath("/"); 203 | resolve({ status: "success", output: rawOutput }); 204 | } catch (err) { 205 | console.error( 206 | `killContainer(${id}) error processing output: ${(err as Error).message}`, 207 | ); 208 | createEvent( 209 | "Error", 210 | `Error processing output: ${(err as Error).message}`, 211 | ); 212 | revalidatePath("/"); 213 | reject(new Error(`Error processing output: ${(err as Error).message}`)); 214 | } 215 | }); 216 | }); 217 | } 218 | 219 | export async function deleteContainer(id: string) { 220 | console.info(`Deleting container with ID: ${id}`); 221 | return new Promise((resolve, reject) => { 222 | const ls = spawn("docker", ["container", "rm", "-v", id]); 223 | let dataChunks: Buffer[] = []; 224 | let errorChunks: Buffer[] = []; 225 | 226 | ls.stdout.on("data", (data) => { 227 | dataChunks.push(data); 228 | }); 229 | 230 | ls.stderr.on("data", (data) => { 231 | errorChunks.push(data); 232 | handleStderr("deleteContainer", id, data, "Delete"); 233 | }); 234 | 235 | ls.on("error", (error) => { 236 | handleProcessError("deleteContainer", id, error, reject); 237 | }); 238 | 239 | ls.on("close", (code) => { 240 | if ( 241 | !handleProcessClose( 242 | "deleteContainer", 243 | id, 244 | code ?? 1, 245 | errorChunks, 246 | reject, 247 | ) 248 | ) { 249 | return; 250 | } 251 | 252 | try { 253 | const rawOutput = Buffer.concat(dataChunks).toString().trim(); 254 | console.info(`deleteContainer(${id}) success: ${rawOutput}`); 255 | createEvent("Success", `Container ${id} deleted successfully`); 256 | revalidatePath("/"); 257 | resolve({ status: "success", output: rawOutput }); 258 | } catch (err) { 259 | console.error( 260 | `deleteContainer(${id}) error processing output: ${(err as Error).message}`, 261 | ); 262 | createEvent( 263 | "Error", 264 | `Error processing output: ${(err as Error).message}`, 265 | ); 266 | revalidatePath("/"); 267 | reject(new Error(`Error processing output: ${(err as Error).message}`)); 268 | } 269 | }); 270 | }); 271 | } 272 | 273 | export async function restartContainer(id: string) { 274 | console.info(`Restarting container with ID: ${id}`); 275 | return new Promise((resolve, reject) => { 276 | const ls = spawn("docker", ["restart", id]); 277 | let dataChunks: Buffer[] = []; 278 | let errorChunks: Buffer[] = []; 279 | 280 | ls.stdout.on("data", (data) => { 281 | dataChunks.push(data); 282 | }); 283 | 284 | ls.stderr.on("data", (data) => { 285 | errorChunks.push(data); 286 | handleStderr("restartContainer", id, data, "Restart"); 287 | }); 288 | 289 | ls.on("error", (error) => { 290 | handleProcessError("restartContainer", id, error, reject); 291 | }); 292 | 293 | ls.on("close", (code) => { 294 | if ( 295 | !handleProcessClose( 296 | "restartContainer", 297 | id, 298 | code ?? 1, 299 | errorChunks, 300 | reject, 301 | ) 302 | ) { 303 | return; 304 | } 305 | 306 | try { 307 | const rawOutput = Buffer.concat(dataChunks).toString().trim(); 308 | console.info(`restartContainer(${id}) success: ${rawOutput}`); 309 | createEvent("Success", `Container ${id} restarted successfully`); 310 | revalidatePath("/"); 311 | resolve({ status: "success", output: rawOutput }); 312 | } catch (err) { 313 | console.error( 314 | `restartContainer(${id}) error processing output: ${(err as Error).message}`, 315 | ); 316 | createEvent( 317 | "Error", 318 | `Error processing output: ${(err as Error).message}`, 319 | ); 320 | revalidatePath("/"); 321 | reject(new Error(`Error processing output: ${(err as Error).message}`)); 322 | } 323 | }); 324 | }); 325 | } 326 | 327 | export async function clone() { 328 | return new Promise((resolve, reject) => { 329 | const workingDir = process.env.WORKING_DIR || "/app/data"; 330 | const repoRoot = process.env.REPO_URL; 331 | 332 | if (!repoRoot) { 333 | const errorMessage = 334 | "Required environment variable REPO_URL is not defined"; 335 | console.error(`clone() ${errorMessage}`); 336 | createEvent("Error", "Missing REPO_URL environment variable"); 337 | reject(new Error(errorMessage)); 338 | return; 339 | } 340 | 341 | if (!process.env.WORKING_DIR) { 342 | console.warn( 343 | "No WORKING_DIR environment variable. Using default path `/app/data/` -- ensure this path is mounted!", 344 | ); 345 | createEvent( 346 | "Info", 347 | "No WORKING_DIR environment variable. Using default path `/app/data/` -- ensure this path is mounted!", 348 | ); 349 | } 350 | 351 | const repoName = process.env.REPO_NAME; 352 | const repoPath = `${workingDir}/${repoName}`; 353 | 354 | // Detect SSH or HTTPS/PAT repo URL 355 | const isSSH = repoRoot.startsWith("git@"); 356 | const isPAT = repoRoot.startsWith("https://"); 357 | 358 | // If using SSH, set GIT_SSH_COMMAND to avoid host key prompt (optional) 359 | // You may want to customize this for your environment 360 | const env = { ...process.env }; 361 | if (isSSH) { 362 | env.GIT_SSH_COMMAND = "ssh -o StrictHostKeyChecking=accept-new"; 363 | } 364 | 365 | const checkDirCmd = `if [ -d "${repoPath}" ]; then 366 | echo "exists"; 367 | cd "${repoPath}" && git fetch --all && git pull && echo "${repoPath}"; 368 | else 369 | echo "new"; 370 | cd "${workingDir}" && git clone "${repoRoot}" && echo "${repoPath}"; 371 | fi`; 372 | 373 | const ls = spawn("sh", ["-c", checkDirCmd], { env }); 374 | let dataChunks: Buffer[] = []; 375 | let errorChunks: Buffer[] = []; 376 | 377 | ls.stdout.on("data", (data) => { 378 | dataChunks.push(data); 379 | }); 380 | 381 | ls.stderr.on("data", (data) => { 382 | errorChunks.push(data); 383 | const stderrText = data.toString().trim(); 384 | 385 | // Check if this is actually an error or just git info 386 | const isActualError = 387 | stderrText.includes("fatal:") || 388 | stderrText.includes("error:") || 389 | stderrText.includes("permission denied"); 390 | 391 | if (isActualError) { 392 | console.error(`clone() stderr: ${data}`); 393 | createEvent("Error", `Clone stderr: ${data}`); 394 | if (process.env.NTFY_URL) { 395 | fetch(`${process.env.NTFY_URL}`, { 396 | method: "POST", // PUT works too 397 | body: `Clone stderr: ${data}`, 398 | headers: { 399 | "X-Title": "~~SID~~ - Error Cloning", 400 | }, 401 | }); 402 | } 403 | } else { 404 | console.info(`clone() git info: ${data}`); 405 | } 406 | }); 407 | 408 | ls.on("error", (error) => { 409 | handleProcessError("clone", null, error, reject); 410 | }); 411 | 412 | ls.on("close", (code) => { 413 | if (!handleProcessClose("clone", null, code ?? 1, errorChunks, reject)) { 414 | return; 415 | } 416 | 417 | try { 418 | const output = Buffer.concat(dataChunks).toString().trim(); 419 | const lines = output.split("\n"); 420 | const resultPath = lines[lines.length - 1]; 421 | 422 | if (lines[0] === "exists") { 423 | console.info( 424 | `clone() repository already exists at ${resultPath}, pulled latest changes.`, 425 | ); 426 | createEvent( 427 | "Info", 428 | `Repository already exists, pulled latest changes at ${resultPath}`, 429 | ); 430 | revalidatePath("/"); 431 | resolve({ 432 | status: "success", 433 | path: resultPath, 434 | message: "Repository already exists, pulled latest changes", 435 | }); 436 | } else { 437 | console.info(`clone() repository newly cloned to ${resultPath}.`); 438 | createEvent("Info", `Repository newly cloned to ${resultPath}`); 439 | if (process.env.NTFY_URL) { 440 | fetch(`${process.env.NTFY_URL}`, { 441 | method: "POST", // PUT works too 442 | body: `Repository newly cloned to ${resultPath}`, 443 | headers: { 444 | "X-Title": "~~SID~~ - Success", 445 | }, 446 | }); 447 | } 448 | revalidatePath("/"); 449 | resolve({ 450 | status: "success", 451 | path: resultPath, 452 | message: "Repository newly cloned", 453 | }); 454 | } 455 | } catch (err) { 456 | console.error( 457 | `clone() error processing output: ${(err as Error).message}`, 458 | ); 459 | createEvent( 460 | "Error", 461 | `Error processing output: ${(err as Error).message}`, 462 | ); 463 | revalidatePath("/"); 464 | reject(new Error(`Error processing output: ${(err as Error).message}`)); 465 | } 466 | }); 467 | }); 468 | } 469 | 470 | export async function runDockerComposeForChangedDirs( 471 | files: string[], 472 | ): Promise<{ dir: string; result: string; error?: string }[]> { 473 | let workingDir = process.env.WORKING_DIR || "/app/data"; 474 | 475 | if (!workingDir) { 476 | console.warn( 477 | "No WORKING_DIR environment variable. Using default path `/app/data/` -- ensure this path is mounted!", 478 | ); 479 | } 480 | 481 | if (!process.env.REPO_URL) { 482 | throw new Error("Required environment variable REPO_URL is not set"); 483 | } 484 | 485 | const repoName = process.env.REPO_URL?.split("/").pop()?.replace(".git", ""); 486 | workingDir = `${workingDir}/${repoName}`; 487 | 488 | // Get unique directories from file paths 489 | const dirs = Array.from( 490 | new Set( 491 | files.map((f) => f.split("/").slice(0, -1).join("/")).filter(Boolean), 492 | ), 493 | ); 494 | 495 | const results: { dir: string; result: string; error?: string }[] = []; 496 | 497 | for (const dir of dirs) { 498 | const absDir = `${workingDir}/${dir}`; 499 | console.info(`Running docker compose in: ${absDir}`); 500 | 501 | try { 502 | const result = await new Promise((resolve, reject) => { 503 | const proc = spawn( 504 | "docker", 505 | ["compose", "up", "-d", "--remove-orphans"], 506 | { cwd: absDir }, 507 | ); 508 | let output = ""; 509 | let errorOutput = ""; 510 | 511 | proc.stdout.on("data", (data) => { 512 | output += data.toString(); 513 | }); 514 | 515 | proc.stderr.on("data", (data) => { 516 | errorOutput += data.toString(); 517 | }); 518 | 519 | proc.on("error", (error) => { 520 | const message = `Failed to start docker compose in ${absDir}: ${error.message}`; 521 | console.error(message); 522 | createEvent("Error", message, dir.split("/")[0]); 523 | if (process.env.NTFY_URL) { 524 | fetch(`${process.env.NTFY_URL}`, { 525 | method: "POST", // PUT works too 526 | body: `Error: {message}`, 527 | headers: { 528 | "X-Title": "~~SID~~ - Failed to start docker compose", 529 | }, 530 | }); 531 | } 532 | reject(error); 533 | }); 534 | 535 | proc.on("close", (code) => { 536 | if (code === 0) { 537 | console.info( 538 | `docker compose up succeeded in ${absDir}: ${output.trim()}`, 539 | ); 540 | createEvent( 541 | "Success", 542 | `docker compose up succeeded in ${absDir}`, 543 | dir.split("/")[0], 544 | ); 545 | if (process.env.NTFY_URL) { 546 | fetch(`${process.env.NTFY_URL}`, { 547 | method: "POST", // PUT works too 548 | body: `docker compose up succeeded in ${absDir}`, 549 | headers: { 550 | "X-Title": "~~SID~~ - Success", 551 | }, 552 | }); 553 | } 554 | resolve(output.trim()); 555 | } else { 556 | const errorMessage = 557 | errorOutput.trim() || `Exited with code ${code}`; 558 | console.error( 559 | `docker compose up failed in ${absDir}: ${errorMessage}`, 560 | ); 561 | createEvent( 562 | "Error", 563 | `docker compose up failed in ${absDir}: ${errorMessage}`, 564 | dir.split("/")[0], 565 | ); 566 | if (process.env.NTFY_URL) { 567 | fetch(`${process.env.NTFY_URL}`, { 568 | method: "POST", // PUT works too 569 | body: `docker compose up failed in ${absDir}: ${errorMessage}`, 570 | headers: { 571 | "X-Title": "~~SID~~ - Failed to start docker compose", 572 | }, 573 | }); 574 | } 575 | reject(new Error(errorMessage)); 576 | } 577 | }); 578 | }); 579 | 580 | results.push({ dir: absDir, result }); 581 | } catch (err: any) { 582 | console.error( 583 | `Error running docker compose in ${absDir}: ${err.message}`, 584 | ); 585 | createEvent( 586 | "Error", 587 | `Error running docker compose in ${absDir}: ${err.message}`, 588 | dir.split("/")[0], 589 | ); 590 | results.push({ dir: absDir, result: "", error: err.message }); 591 | } 592 | } 593 | 594 | return results; 595 | } 596 | 597 | export async function findAllDockerComposeFiles(): Promise { 598 | const workingDir = process.env.WORKING_DIR || "/app/data"; 599 | const repoRoot = process.env.REPO_URL; 600 | 601 | if (!repoRoot) { 602 | throw new Error("Required environment variable REPO_URL is not set"); 603 | } 604 | 605 | const repoName = repoRoot.split("/").pop()?.replace(".git", ""); 606 | if (!repoName) { 607 | throw new Error("Unable to determine repository name from REPO_URL"); 608 | } 609 | 610 | if (!process.env.WORKING_DIR) { 611 | console.warn( 612 | "No WORKING_DIR environment variable. Using default path `/app/data/` -- ensure this path is mounted!", 613 | ); 614 | } 615 | 616 | const rootDir = join(workingDir, repoName); 617 | const results: string[] = []; 618 | 619 | function walk(dir: string) { 620 | let entries: string[]; 621 | try { 622 | entries = readdirSync(dir); 623 | } catch (err) { 624 | console.error( 625 | `Error reading directory ${dir}: ${(err as Error).message}`, 626 | ); 627 | return; 628 | } 629 | 630 | for (const entry of entries) { 631 | const fullPath = join(dir, entry); 632 | let stats; 633 | 634 | try { 635 | stats = statSync(fullPath); 636 | } catch (err) { 637 | console.error( 638 | `Error stating path ${fullPath}: ${(err as Error).message}`, 639 | ); 640 | continue; 641 | } 642 | 643 | if (stats.isDirectory()) { 644 | walk(fullPath); 645 | } else if ( 646 | entry === "docker-compose.yml" || 647 | entry === "docker-compose.yaml" 648 | ) { 649 | results.push(fullPath); 650 | } 651 | } 652 | } 653 | 654 | walk(rootDir); 655 | return results; 656 | } 657 | 658 | export async function runDockerComposeForPath( 659 | path: string, 660 | ): Promise<{ dir: string; result: string; error?: string }> { 661 | const workingDir = process.env.WORKING_DIR; 662 | 663 | if (!workingDir) { 664 | throw new Error("WORKING_DIR environment variable is not set"); 665 | } 666 | 667 | // Remove leading/trailing slashes and construct absolute directory path 668 | const cleanPath = path.replace(/^\/|\/$/g, ""); 669 | const absDir = `${workingDir}/${cleanPath}`; 670 | console.log("stack name: ", cleanPath.split("/")[1]); 671 | console.info(`Running docker compose in: ${absDir}`); 672 | 673 | try { 674 | const result = await new Promise((resolve, reject) => { 675 | const proc = spawn( 676 | "docker", 677 | ["compose", "up", "-d", "--remove-orphans"], 678 | { cwd: absDir }, 679 | ); 680 | let output = ""; 681 | let errorOutput = ""; 682 | 683 | proc.stdout.on("data", (data) => { 684 | output += data.toString(); 685 | }); 686 | 687 | proc.stderr.on("data", (data) => { 688 | errorOutput += data.toString(); 689 | }); 690 | 691 | proc.on("error", (error) => { 692 | const message = `Failed to start docker compose in ${absDir}: ${error.message}`; 693 | console.error(message); 694 | createEvent("Error", message, cleanPath.split("/")[1]); 695 | revalidatePath("/"); 696 | reject(error); 697 | }); 698 | 699 | proc.on("close", (code) => { 700 | if (code === 0) { 701 | console.info( 702 | `docker compose up succeeded in ${absDir}: ${output.trim()}`, 703 | ); 704 | createEvent( 705 | "Success", 706 | `docker compose up succeeded in ${absDir}`, 707 | cleanPath.split("/")[1], 708 | ); 709 | revalidatePath("/"); 710 | resolve(output.trim()); 711 | } else { 712 | const errorMessage = errorOutput.trim() || `Exited with code ${code}`; 713 | console.error( 714 | `docker compose up failed in ${absDir}: ${errorMessage}`, 715 | ); 716 | createEvent( 717 | "Error", 718 | `docker compose up failed in ${absDir}: ${errorMessage}`, 719 | cleanPath.split("/")[1], 720 | ); 721 | revalidatePath("/"); 722 | reject(new Error(errorMessage)); 723 | } 724 | }); 725 | }); 726 | 727 | return { dir: absDir, result }; 728 | } catch (err: any) { 729 | console.error(`Error running docker compose in ${absDir}: ${err.message}`); 730 | createEvent( 731 | "Error", 732 | `Error running docker compose in ${absDir}: ${err.message}`, 733 | cleanPath.split("/")[1], 734 | ); 735 | revalidatePath("/"); 736 | return { dir: absDir, result: "", error: err.message }; 737 | } 738 | } 739 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | export function middleware(req: Request) { 4 | const url = new URL(req.url); 5 | 6 | // Allow /api routes to bypass host validation 7 | if (url.pathname.startsWith("/api")) { 8 | return NextResponse.next(); 9 | } 10 | 11 | // Check the Host header, if SID_ALLOWED_HOSTS is set 12 | const host = req.headers.get("host"); 13 | const port = process.env.PORT || 3000; 14 | 15 | let allowedHosts = [`localhost:${port}`, `127.0.0.1:${port}`]; 16 | const allowAll = process.env.SID_ALLOWED_HOSTS === "*"; 17 | if (process.env.SID_ALLOWED_HOSTS) { 18 | allowedHosts = allowedHosts.concat(process.env.SID_ALLOWED_HOSTS.split(",")); 19 | } 20 | if (!allowAll && (!host || !allowedHosts.includes(host))) { 21 | // eslint-disable-next-line no-console 22 | console.error( 23 | `Host validation failed for: ${host}. Hint: Set the SID_ALLOWED_HOSTS environment variable to allow requests from this host / port.`, 24 | ); 25 | return NextResponse.json({ error: "Host validation failed. See logs for more details." }, { status: 400 }); 26 | } 27 | return NextResponse.next(); 28 | } 29 | 30 | export const config = { 31 | matcher: "/:path*", 32 | }; -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | output: 'standalone', 5 | pageExtensions: ['ts', 'tsx'] 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sid", 3 | "homepage": "https://github.com/declan-wade/SID", 4 | "version": "1.0.0", 5 | "license": "MIT License", 6 | "private": false, 7 | "bugs": { 8 | "url": "https://github.com/declan-wade/SID/issues" 9 | }, 10 | "scripts": { 11 | "dev": "next dev --turbopack", 12 | "build": "next build --experimental-build-mode compile", 13 | "start": "next start", 14 | "lint": "next lint", 15 | "postinstall": "prisma generate" 16 | }, 17 | "dependencies": { 18 | "@octokit/core": "^7.0.3", 19 | "@octokit/webhooks-methods": "^6.0.0", 20 | "@prisma/client": "^6.11.1", 21 | "@radix-ui/react-alert-dialog": "^1.1.14", 22 | "@radix-ui/react-dialog": "^1.1.6", 23 | "@radix-ui/react-label": "^2.1.7", 24 | "@radix-ui/react-slot": "^1.2.3", 25 | "@radix-ui/react-switch": "^1.2.5", 26 | "@radix-ui/react-tooltip": "^1.1.8", 27 | "class-variance-authority": "^0.7.1", 28 | "clsx": "^2.1.1", 29 | "date-fns": "^4.1.0", 30 | "lucide-react": "^0.525.0", 31 | "next": "15.2.4", 32 | "next-themes": "^0.4.6", 33 | "prisma": "^6.11.1", 34 | "react": "^19.1.0", 35 | "react-dom": "^19.1.0", 36 | "sonner": "^2.0.3", 37 | "tailwind-merge": "^3.3.1", 38 | "tw-animate-css": "^1.3.6", 39 | "vaul": "^1.1.2" 40 | }, 41 | "devDependencies": { 42 | "@tailwindcss/postcss": "^4", 43 | "@types/node": "^24", 44 | "@types/react": "^19", 45 | "@types/react-dom": "^19", 46 | "tailwindcss": "^4", 47 | "typescript": "^5" 48 | }, 49 | "pnpm": { 50 | "supportedArchitectures": { 51 | "os": [ 52 | "current" 53 | ], 54 | "cpu": [ 55 | "x64", 56 | "arm64" 57 | ] 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20250710011123_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Stack" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "path" TEXT NOT NULL, 6 | "status" TEXT NOT NULL, 7 | "lastEvent" TEXT, 8 | "lastEventAt" TIMESTAMP(3), 9 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | "updatedAt" TIMESTAMP(3) NOT NULL, 11 | 12 | CONSTRAINT "Stack_pkey" PRIMARY KEY ("id") 13 | ); 14 | 15 | -- CreateTable 16 | CREATE TABLE "Event" ( 17 | "id" TEXT NOT NULL, 18 | "type" TEXT NOT NULL, 19 | "message" TEXT NOT NULL, 20 | "stackId" TEXT, 21 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 22 | 23 | CONSTRAINT "Event_pkey" PRIMARY KEY ("id") 24 | ); 25 | 26 | -- CreateIndex 27 | CREATE UNIQUE INDEX "Stack_name_key" ON "Stack"("name"); 28 | 29 | -- AddForeignKey 30 | ALTER TABLE "Event" ADD CONSTRAINT "Event_stackId_fkey" FOREIGN KEY ("stackId") REFERENCES "Stack"("id") ON DELETE SET NULL ON UPDATE CASCADE; 31 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" 4 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "postgresql" 6 | url = env("DB_URL") 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | } 12 | 13 | model Stack { 14 | id String @id @default(uuid()) 15 | name String @unique 16 | path String 17 | status String 18 | lastEvent String? 19 | lastEventAt DateTime? 20 | createdAt DateTime @default(now()) 21 | updatedAt DateTime @updatedAt 22 | events Event[] @relation("StackToEvents") 23 | } 24 | 25 | model Event { 26 | id String @id @default(uuid()) 27 | type String 28 | message String 29 | stackId String? // Optional relation to Stack 30 | createdAt DateTime @default(now()) 31 | stack Stack? @relation("StackToEvents", fields: [stackId], references: [id]) 32 | } 33 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------