diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..138e5a1 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(ssh:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index 3569642..46e9309 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ package-lock.json .env.local .env.*.local config.json +deployment-config.json # Docker artifacts *.tar @@ -34,3 +35,6 @@ coverage/ # Temporary files tmp/ temp/ + +# Vite build output +app/renderer/dist/ diff --git a/README.md b/README.md index 9a67a4a..c12eefa 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,703 @@ -# Docker Deployment Manager +# Docker Deployment Manager → Coolify Migration -Automated Docker deployment system for containerizing and deploying projects from Windows to Linux. +This project started as a custom Docker deployment tool (CLI + Electron desktop app) for deploying from Windows to Linux servers via SSH/tar transfers. After evaluating the approach, we're migrating to **Coolify** — an open-source self-hosted PaaS that handles the entire deploy pipeline automatically. -## Overview +## Why Coolify Instead of a Custom Tool? -**Phase 1 (Current):** CLI tool to generate Dockerfiles, docker-compose, and deployment scripts. +| Custom Tool | Coolify | +|---|---| +| Manual button clicks to deploy | Auto-deploys on `git push` | +| SSH password stored in plaintext `.env` | SSH keys / deploy keys | +| Tar-based image transfer (no registry) | Builds on server, no transfer needed | +| Desktop app required on Windows | Web dashboard from any browser | +| Custom file-diff comparison | Git is the source of truth | +| One developer maintaining it | Thousands of contributors | -**Phase 2 (Planned):** Electron desktop app for central management and visibility across all projects. +## Architecture -## Quick Start +``` +Windows (dev machine) 192.168.69.4 (box-stableapps) +┌──────────────────────┐ ┌──────────────────────────────┐ +│ C:\.bucket\repos.gitea│ │ Coolify (port 2010) │ +│ │ │ ├── Dashboard + DB │ +│ edit code │ │ ├── Traefik proxy │ +│ → git commit │ │ └── Manages deployments │ +│ → git push │ │ │ +│ │ │ │ Gitea (ports 2003/2004) │ +│ ▼ │ │ ├── HTTP: 2003 │ +│ Gitea (webhook) ────────────► │ └── SSH: 2004 │ +│ gitea.clintmasden │ │ │ +│ .duckdns.org │ │ Data: ~/containers/*/data/ │ +└──────────────────────┘ └──────────────────────────────┘ + │ + │ SSH (root) + ▼ + 192.168.69.5 (box-repoapps) + ┌──────────────────────────────┐ + │ Deployed apps: │ + │ ├── learnxiny │ + │ ├── actualbudget.report │ + │ └── ... │ + │ │ + │ Data: ~/containers/*/data/ │ + └──────────────────────────────┘ -```powershell -# 1. Install dependencies -npm install - -# 2. Detect a project type -npm run docker-deploy -- detect "C:\.bucket\Repos.Git\game.justone" - -# 3. Initialize Docker config for a project -npm run docker-deploy -- init "C:\.bucket\Repos.Git\game.justone" - -# 4. Build and deploy (from the target project directory) -cd C:\.bucket\Repos.Git\game.justone -.\build-image-tar.ps1 # Manual: creates tar, shows instructions -.\deploy-docker-auto.ps1 # Automated: builds, transfers, starts on server +Flow: edit → git push → Gitea webhook → Coolify auto-builds & deploys ``` -## Commands +## Server Setup -### detect -Identify project type without generating files. - -```powershell -npm run docker-deploy -- detect [path] -npm run docker-deploy -- detect "C:\.bucket\Repos.Git\dotrepo.timer" -``` - -### init -Generate Docker configuration for a project. - -```powershell -npm run docker-deploy -- init [path] -npm run docker-deploy -- init "C:\.bucket\Repos.Git\game.justone" - -# Options: -# --no-interactive Skip prompts, use defaults -# --type Force specific project type -# --port Override default port -# --dry-run Show what would be generated -# --overwrite Replace existing files -``` - -### batch -Operations across multiple projects. - -```powershell -# Scan all projects and show report -npm run docker-deploy -- batch detect --root "C:\.bucket\Repos.Git" --report - -# Initialize all projects -npm run docker-deploy -- batch init --root "C:\.bucket\Repos.Git" -``` - -## Supported Project Types - -| Type | Detection | Template | -|------|-----------|----------| -| Node.js Express | `package.json` with express | Multi-stage build | -| Node.js Vite+React | `package.json` with vite + react | Nginx static | -| Node.js Vite+React SSR | `package.json` with vite + react + express | Node runtime | -| Python Standard | `requirements.txt` | Python slim | -| Python ML/PyTorch | `requirements.txt` with torch | Python + system deps | -| .NET Blazor | `.csproj` with Blazor | SDK + ASP.NET runtime | -| Static Site | `index.html` | Nginx | - -## Generated Files - -When you run `init`, these files are created in the target project: - -- `Dockerfile` - Container build instructions -- `docker-compose.yml` - Runtime configuration -- `.dockerignore` - Files excluded from image -- `docker-deployment.json` - Project configuration -- `.env.example` - Environment variables template -- `deploy-docker-auto.ps1` - Automated SSH deployment script -- `build-image-tar.ps1` - Manual build script -- `README.DOCKER.md` - Deployment documentation - -## Deployment Workflow - -### Option 1: Automated (SSH) -```powershell -.\deploy-docker-auto.ps1 -# Builds image → creates tar → SCPs to server → loads → starts -``` - -### Option 2: Manual -```powershell -.\build-image-tar.ps1 -# Then follow the printed instructions to SCP and deploy -``` - -### On the Linux Server -```bash -cd ~/containers/project-name/files -docker load -i project-name.tar -docker compose up -d -docker compose logs -f -``` - -## Configuration - -### Per-Project: docker-deployment.json -```json -{ - "project": { "name": "game-justone", "type": "nodejs-express" }, - "runtime": { "port": 3000 }, - "deployment": { - "sshHost": "192.168.8.178", - "sshUser": "deployer", - "targetPath": "~/containers/game-justone/files" - } -} -``` - -### Global: global-deployment-config.json -Default SSH settings used when initializing new projects. - -## Phase 2 Roadmap - -Electron desktop app with: -- Central view of all 35+ projects -- Docker status: configured / deployed / running -- One-click init, build, deploy -- Real-time logs and status updates +- **192.168.69.4** (box-stableapps): Coolify host + Gitea host + stable apps +- **192.168.69.5** (box-repoapps): Working/repo apps, managed as remote server by Coolify --- -Built for managing Docker deployments across the Gitea repository collection. + +## Coolify Installation (on 192.168.69.4) + +Coolify runs as Docker containers at `~/containers/coolify/`, consistent with the existing container layout. + +### 1. Create directories + +```bash +mkdir -p ~/containers/coolify/data/{source,ssh,applications,databases,backups,services,proxy,sentinel} +mkdir -p ~/containers/coolify/data/ssh/{keys,mux} +mkdir -p ~/containers/coolify/data/proxy/dynamic +``` + +### 2. Create the .env file + +```bash +tee ~/containers/coolify/data/source/.env << 'ENVEOF' +APP_ID=coolify +APP_NAME=Coolify +APP_ENV=production +APP_KEY= +DB_USERNAME=coolify +DB_PASSWORD= +REDIS_PASSWORD= +PUSHER_APP_ID= +PUSHER_APP_KEY= +PUSHER_APP_SECRET= +APP_PORT=2010 +SOKETI_PORT=6001 +REGISTRY_URL=ghcr.io +ENVEOF +``` + +Generate secrets: + +```bash +sed -i "s|^APP_KEY=.*|APP_KEY=base64:$(openssl rand -base64 32)|" ~/containers/coolify/data/source/.env +sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=$(openssl rand -base64 32)|" ~/containers/coolify/data/source/.env +sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=$(openssl rand -base64 32)|" ~/containers/coolify/data/source/.env +sed -i "s|^PUSHER_APP_ID=.*|PUSHER_APP_ID=$(openssl rand -hex 32)|" ~/containers/coolify/data/source/.env +sed -i "s|^PUSHER_APP_KEY=.*|PUSHER_APP_KEY=$(openssl rand -hex 32)|" ~/containers/coolify/data/source/.env +sed -i "s|^PUSHER_APP_SECRET=.*|PUSHER_APP_SECRET=$(openssl rand -hex 32)|" ~/containers/coolify/data/source/.env +``` + +### 3. Create docker-compose.yml + +Use a single merged compose file at `~/containers/coolify/data/source/docker-compose.yml`. Key customizations from default: + +- All paths use `/home/clint/containers/coolify/data/` instead of `/data/coolify/` +- Named volumes replaced with bind mounts (postgres, redis) +- `APP_PORT=2010` instead of default 8000 +- `host.docker.internal` enabled for accessing other containers on the host +- Gitea known_hosts mounted for SSH git access + +```yaml +services: + coolify: + image: "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}" + container_name: coolify + restart: always + working_dir: /var/www/html + extra_hosts: + - host.docker.internal:host-gateway + volumes: + - type: bind + source: /home/clint/containers/coolify/data/source/.env + target: /var/www/html/.env + read_only: true + - /home/clint/containers/coolify/data/ssh:/var/www/html/storage/app/ssh + - /home/clint/containers/coolify/data/applications:/var/www/html/storage/app/applications + - /home/clint/containers/coolify/data/databases:/var/www/html/storage/app/databases + - /home/clint/containers/coolify/data/services:/var/www/html/storage/app/services + - /home/clint/containers/coolify/data/backups:/var/www/html/storage/app/backups + - /home/clint/containers/coolify/data/known_hosts:/root/.ssh/known_hosts:ro + environment: + - APP_ENV=${APP_ENV:-production} + - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} + env_file: + - /home/clint/containers/coolify/data/source/.env + ports: + - "${APP_PORT:-8000}:8080" + expose: + - "${APP_PORT:-8000}" + healthcheck: + test: curl --fail http://127.0.0.1:8080/api/health || exit 1 + interval: 5s + retries: 10 + timeout: 2s + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + soketi: + condition: service_healthy + networks: + - coolify + + postgres: + image: postgres:15-alpine + container_name: coolify-db + restart: always + volumes: + - /home/clint/containers/coolify/data/postgres:/var/lib/postgresql/data + environment: + POSTGRES_USER: "${DB_USERNAME}" + POSTGRES_PASSWORD: "${DB_PASSWORD}" + POSTGRES_DB: "${DB_DATABASE:-coolify}" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}"] + interval: 5s + retries: 10 + timeout: 2s + networks: + - coolify + + redis: + image: redis:7-alpine + container_name: coolify-redis + restart: always + command: redis-server --save 20 1 --loglevel warning --requirepass ${REDIS_PASSWORD} + environment: + REDIS_PASSWORD: "${REDIS_PASSWORD}" + volumes: + - /home/clint/containers/coolify/data/redis:/data + healthcheck: + test: redis-cli ping + interval: 5s + retries: 10 + timeout: 2s + networks: + - coolify + + soketi: + image: "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10" + container_name: coolify-realtime + restart: always + extra_hosts: + - host.docker.internal:host-gateway + ports: + - "${SOKETI_PORT:-6001}:6001" + - "6002:6002" + volumes: + - /home/clint/containers/coolify/data/ssh:/var/www/html/storage/app/ssh + environment: + APP_NAME: "${APP_NAME:-Coolify}" + SOKETI_DEBUG: "${SOKETI_DEBUG:-false}" + SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" + SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" + SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] + interval: 5s + retries: 10 + timeout: 2s + networks: + - coolify + +networks: + coolify: + name: coolify + external: true +``` + +### 4. Set up Traefik proxy + +Coolify uses Traefik as its reverse proxy. The proxy has its own docker-compose at `~/containers/coolify/data/proxy/docker-compose.yml`: + +```yaml +name: coolify-proxy +networks: + coolify: + external: true +services: + traefik: + container_name: coolify-proxy + image: 'traefik:v3.6' + restart: unless-stopped + extra_hosts: + - 'host.docker.internal:host-gateway' + networks: + - coolify + ports: + - '80:80' + - '443:443' + - '443:443/udp' + - '8080:8080' + healthcheck: + test: 'wget -qO- http://localhost:80/ping || exit 1' + interval: 4s + timeout: 2s + retries: 5 + volumes: + - '/var/run/docker.sock:/var/run/docker.sock:ro' + - '/home/clint/containers/coolify/data/proxy/:/traefik' + command: + - '--ping=true' + - '--ping.entrypoint=http' + - '--api.dashboard=true' + - '--entrypoints.http.address=:80' + - '--entrypoints.https.address=:443' + - '--entrypoints.http.http.encodequerysemicolons=true' + - '--entryPoints.http.http2.maxConcurrentStreams=250' + - '--entrypoints.https.http.encodequerysemicolons=true' + - '--entryPoints.https.http2.maxConcurrentStreams=250' + - '--entrypoints.https.http3' + - '--providers.file.directory=/traefik/dynamic/' + - '--providers.file.watch=true' + - '--certificatesresolvers.letsencrypt.acme.httpchallenge=true' + - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=http' + - '--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json' + - '--api.insecure=false' + - '--providers.docker=true' + - '--providers.docker.exposedbydefault=false' + labels: + - traefik.enable=true + - traefik.http.routers.traefik.entrypoints=http + - traefik.http.routers.traefik.service=api@internal + - traefik.http.services.traefik.loadbalancer.server.port=8080 + - coolify.managed=true + - coolify.proxy=true +``` + +Key: the volume mount points to `/home/clint/containers/coolify/data/proxy/` (not `/data/coolify/proxy/`). + +### 5. Create Docker network and start + +**Run these as separate commands:** + +```bash +sudo docker network create --attachable coolify +``` + +```bash +# Start the proxy first +cd ~/containers/coolify/data/proxy +sudo docker compose up -d + +# Then start Coolify +cd ~/containers/coolify/data/source +sudo docker compose up -d --pull always --remove-orphans --force-recreate +``` + +### 6. Fix data directory permissions + +Each service needs specific ownership. **Do NOT blanket chown the entire data directory** — Postgres and Redis run as different UIDs inside their containers. + +```bash +# Postgres must be UID 70 (postgres user in Alpine) +sudo chown -R 70:70 ~/containers/coolify/data/postgres + +# Redis must be UID 999 (redis user in Alpine) +sudo chown -R 999:999 ~/containers/coolify/data/redis + +# SSH keys — Coolify runs as UID 9999 +sudo chown -R 9999:root ~/containers/coolify/data/ssh +sudo chmod -R 700 ~/containers/coolify/data/ssh + +# Proxy — root-owned, acme.json must be 600 +sudo chown -R root:root ~/containers/coolify/data/proxy +sudo chmod 600 ~/containers/coolify/data/proxy/acme.json + +# Everything else can be clint +sudo chown -R clint:clint ~/containers/coolify/data/source +sudo chown -R clint:clint ~/containers/coolify/data/applications +sudo chown -R clint:clint ~/containers/coolify/data/databases +sudo chown -R clint:clint ~/containers/coolify/data/services +sudo chown -R clint:clint ~/containers/coolify/data/backups +``` + +If you accidentally `chown -R` the whole directory, fix it with the commands above and restart: `docker restart coolify-db coolify-redis coolify`. + +### 7. Open dashboard + +Go to `http://192.168.69.4:2010` and create admin account (first visitor gets admin). + +--- + +## Gitea SSH Fix (Required for Coolify) + +By default, Gitea's Docker image does NOT start its built-in SSH server. This means SSH keys added through Gitea's web UI won't work for git clone over SSH. Coolify needs SSH to clone private repos. + +### The problem + +Gitea's Docker container has OpenSSH running on port 22 internally, but it's not connected to Gitea's user/key database. Keys added in the Gitea web UI are only used by Gitea's **built-in** SSH server, which is disabled by default. + +### The fix + +Add these environment variables to your Gitea docker-compose (`~/containers/gitea/docker-compose.yml`): + +```yaml + gitea: + environment: + # ... existing env vars ... + GITEA__server__START_SSH_SERVER: "true" + GITEA__server__SSH_LISTEN_PORT: "2222" + GITEA__server__ROOT_URL: "https://gitea.clintmasden.duckdns.org/" + GITEA__server__SSH_DOMAIN: "192.168.69.4" + GITEA__webhook__ALLOWED_HOST_LIST: "192.168.69.4,host.docker.internal" + ports: + - "2003:3000" + - "2004:2222" # was 2004:22, now points to Gitea's built-in SSH +``` + +- `START_SSH_SERVER=true` — enables Gitea's built-in SSH server that reads keys from its database +- `SSH_LISTEN_PORT=2222` — avoids conflict with OpenSSH already on port 22 inside the container +- Port mapping changed from `2004:22` to `2004:2222` to match +- `ROOT_URL` — must match how you access Gitea in the browser. Mismatch causes webhooks not to fire on real pushes +- `SSH_DOMAIN` — keeps the IP for SSH clone URLs (Coolify connects via `ssh://git@192.168.69.4:2004/...`) +- `ALLOWED_HOST_LIST` — Gitea blocks webhooks to local/private IPs by default. This allows webhooks to reach Coolify on the same machine + +Restart Gitea: + +```bash +cd ~/containers/gitea && docker compose up -d +``` + +Verify SSH works: + +```bash +ssh -p 2004 git@192.168.69.4 +# Should respond with: "Hi ! You've successfully authenticated..." +``` + +--- + +## Connecting Coolify to Gitea + +### 1. Generate SSH key in Coolify + +In Coolify dashboard → **Keys & Tokens** → create a new private key (e.g., "gitea-key"). + +### 2. Add public key to Gitea (one-time, all repos) + +Copy the public key from Coolify's key detail page. In Gitea → **User Settings → SSH / GPG Keys → Add Key** → paste it. + +This gives Coolify access to ALL your repos under that user. No need to add deploy keys per-repo. + +### 3. Set up Gitea's known_hosts for Coolify + +The Coolify container needs to trust Gitea's SSH host key. Create a persistent known_hosts file: + +```bash +ssh-keyscan -p 2004 192.168.69.4 2>/dev/null | sudo tee /home/clint/containers/coolify/data/known_hosts > /dev/null +``` + +This file is mounted into the Coolify container via the docker-compose volume: + +```yaml +- /home/clint/containers/coolify/data/known_hosts:/root/.ssh/known_hosts:ro +``` + +### 4. Add remote server (192.168.69.5) + +Coolify needs **root SSH access** to remote servers: + +On 192.168.69.5: +1. Add Coolify's SSH public key to `/root/.ssh/authorized_keys` +2. Ensure `/etc/ssh/sshd_config` has `PermitRootLogin prohibit-password` +3. `sudo systemctl restart sshd` + +In Coolify: **Servers → Add Server** → SSH user `root`, IP `192.168.69.5`. + +Do the same for localhost (192.168.69.4) if you want Coolify to manage apps on the same box. + +### 5. Add an application + +1. **Projects → Add Project** → name it +2. Inside the project → **Add Resource → Application** +3. Choose **Private Repository (with deploy key)** +4. Select the **gitea-key** private key +5. Repository URL (must include port via ssh:// format): + ``` + ssh://git@192.168.69.4:2004/clint/your-repo.git + ``` +6. Choose **Docker Compose** as build pack +7. Click **Load Compose File** +8. Set environment variables in Coolify's UI + +### SSH URL format — important + +Because Gitea SSH is on port 2004 (not 22), you **must** use the `ssh://` URL format: + +``` +ssh://git@192.168.69.4:2004/clint/repo-name.git +``` + +The standard `git@host:user/repo.git` format does NOT support custom ports and will fail. + +--- + +## Deployment Repo Structure + +Each service gets a Gitea repo with Docker config: + +``` +dotrepo.learn.x.in.y/ +├── react/ +│ ├── Dockerfile +│ ├── docker-compose.yml ← absolute volume paths +│ ├── server.js +│ ├── package.json +│ └── src/ +``` + +### Volume paths — absolute, not relative + +```yaml +# WRONG — relative path breaks with Coolify +volumes: + - ./data:/app/data + +# CORRECT — absolute path, data stays where it already is +volumes: + - /home/clint/containers/learnxiny/data:/data +``` + +### Environment variables + +`.env` files stay in `.gitignore` — never committed. Production env vars are managed in Coolify's dashboard per-app. Coolify auto-detects `${VARIABLE}` syntax in compose files and creates UI fields for them. + +--- + +## Auto-deploy Webhook + +The webhook URL and secret are **global** — same for every repo. Coolify matches incoming webhooks to the right app based on the repo URL in the payload. + +### One-time: get the URL and secret from Coolify + +1. Open any app in Coolify → **Webhooks** tab +2. Under **Manual Git Webhooks → Gitea**, copy the URL (e.g., `http://192.168.69.4:2010/webhooks/source/gitea/events/manual`) +3. Set a **Gitea Webhook Secret** and save + +### Per-repo: add webhook in Gitea + +For each Gitea repo you want to auto-deploy: + +1. Repo → **Settings → Webhooks → Add Webhook → Gitea** +2. **Target URL:** the Coolify Gitea webhook URL from above +3. **HTTP Method:** POST +4. **Content Type:** application/json +5. **Secret:** the same secret you set in Coolify +6. **Trigger On:** Push Events +7. Save + +Test by clicking **Test Push Event** in the webhook, then check **Recent Deliveries** for a green 200 response. + +### Gitea webhook allow-list + +Gitea blocks webhooks to private/local IPs by default. The `GITEA__webhook__ALLOWED_HOST_LIST` environment variable in Gitea's docker-compose must include the Coolify host IP — see [Gitea SSH Fix](#gitea-ssh-fix-required-for-coolify) section. + +Now every `git push` triggers: clone → build → deploy → health check — automatically. + +--- + +## Traefik — Replacing Caddy + +Coolify manages Traefik as its built-in reverse proxy. Once all services are migrated, Traefik replaces Caddy entirely. + +### Coolify dashboard domain + +In Coolify → **Settings** → set **Instance's Domain (FQDN)** to: + +``` +https://coolify.clintmasden.duckdns.org +``` + +Coolify auto-configures Traefik to route this domain to its dashboard with SSL. + +### Coolify-managed apps + +For apps deployed through Coolify, set the domain in the app's settings. Coolify configures Traefik automatically — no manual config needed. + +### Non-Coolify services (Gitea, Syncthing, etc.) + +For services NOT managed by Coolify, add custom Traefik config at: + +`~/containers/coolify/data/proxy/dynamic/custom.yml` + +```yaml +http: + routers: + gitea-web: + rule: "Host(`gitea.clintmasden.duckdns.org`)" + entryPoints: + - https + service: gitea-web + tls: + certResolver: letsencrypt + + syncthing: + rule: "Host(`syncthing.clintmasden.duckdns.org`)" + entryPoints: + - https + service: syncthing + tls: + certResolver: letsencrypt + + immich: + rule: "Host(`immich.clintmasden.duckdns.org`)" + entryPoints: + - https + service: immich + tls: + certResolver: letsencrypt + + affine: + rule: "Host(`affine.clintmasden.duckdns.org`)" + entryPoints: + - https + service: affine + tls: + certResolver: letsencrypt + + mattermost: + rule: "Host(`mattermost.clintmasden.duckdns.org`)" + entryPoints: + - https + service: mattermost + tls: + certResolver: letsencrypt + + vikunja: + rule: "Host(`vikunja.clintmasden.duckdns.org`)" + entryPoints: + - https + service: vikunja + tls: + certResolver: letsencrypt + + services: + gitea-web: + loadBalancer: + servers: + - url: "http://host.docker.internal:2003" + + syncthing: + loadBalancer: + servers: + - url: "http://host.docker.internal:2000" + + immich: + loadBalancer: + servers: + - url: "http://host.docker.internal:2001" + + affine: + loadBalancer: + servers: + - url: "http://host.docker.internal:2005" + + mattermost: + loadBalancer: + servers: + - url: "http://host.docker.internal:2006" + + vikunja: + loadBalancer: + servers: + - url: "http://host.docker.internal:2009" +``` + +Traefik picks up changes automatically — no restart needed. + +### Switching from Caddy to Traefik + +1. Stop Caddy: `cd ~/containers/caddy && docker compose down` +2. Verify Traefik grabs ports 80/443: `docker ps | grep traefik` +3. If Traefik isn't binding, restart it: `docker restart coolify-proxy` +4. Test: `curl -I https://gitea.clintmasden.duckdns.org` +5. If something breaks, bring Caddy back: `cd ~/containers/caddy && docker compose up -d` + +### Services that still need migration + +These Caddy static sites need to be containerized (simple nginx containers) before Caddy can be fully retired: + +- `dotrepo.com` + subdomains: fitnotes, racker, timer, travel, pianoxml (React/Angular SPAs served from `/sites/`) +- `audiosphere.dotrepo.com`, `audiocapsule.dotrepo.com`, `audioevents.dotrepo.com`, `everynoise.dotrepo.com` (proxied to 192.168.69.5) +- `clintmasden.duckdns.org` (simple "Hi, I'm Clint" response) + +--- + +## Troubleshooting + +### "Could not resolve hostname ssh/http" +Coolify's custom git repo field **only supports SSH**. It strips protocol prefixes. Use the `ssh://` format with port for non-standard SSH ports. + +### "Permission denied (publickey)" on git clone +1. Verify the right private key is selected in the application settings +2. Verify the matching public key is in Gitea (User Settings → SSH Keys, not repo deploy keys) +3. Test from inside Coolify: `docker exec coolify sh -c "ssh -p 2004 -i /var/www/html/storage/app/ssh/keys/ git@192.168.69.4"` +4. Debug with verbose SSH: `docker exec coolify sh -c "ssh -vvv -p 2004 -i /var/www/html/storage/app/ssh/keys/ -o StrictHostKeyChecking=no git@192.168.69.4 2>&1 | tail -30"` + +### "Host key verification failed" +Regenerate known_hosts: `ssh-keyscan -p 2004 192.168.69.4 2>/dev/null | sudo tee /home/clint/containers/coolify/data/known_hosts > /dev/null` + +### SSH key storage not writable +```bash +sudo chown -R 9999:root ~/containers/coolify/data/ssh +sudo chmod -R 700 ~/containers/coolify/data/ssh +``` + +### 500 error / Redis MISCONF / Postgres permission denied +Usually caused by blanket `chown` on the data directory. Fix ownership per-service and restart: +```bash +sudo chown -R 70:70 ~/containers/coolify/data/postgres +sudo chown -R 999:999 ~/containers/coolify/data/redis +sudo chown -R root:root ~/containers/coolify/data/proxy +sudo chmod 600 ~/containers/coolify/data/proxy/acme.json +docker restart coolify-db coolify-redis coolify +``` + +### Gitea SSH rejects all keys +Enable Gitea's built-in SSH server — see [Gitea SSH Fix](#gitea-ssh-fix-required-for-coolify) section above. + +### Webhook "ALLOWED_HOST_LIST" error +Gitea blocks webhooks to private IPs by default. Add `GITEA__webhook__ALLOWED_HOST_LIST: "192.168.69.4,host.docker.internal"` to Gitea's docker-compose environment and restart. + +### Webhooks fire on test but not on real push +Check Gitea's `ROOT_URL` setting. If it doesn't match the URL you use to access Gitea (e.g., set to `http://192.168.69.4:2003` but you access via `gitea.clintmasden.duckdns.org`), webhooks won't fire on actual push events. Fix by setting `GITEA__server__ROOT_URL` in the docker-compose. + +### Useful debug commands + +List Coolify's SSH keys: +```bash +docker exec coolify sh -c "ls /var/www/html/storage/app/ssh/keys/" +``` + +Show public key for each stored key: +```bash +docker exec coolify sh -c "for key in /var/www/html/storage/app/ssh/keys/*; do echo \"--- \$key ---\"; ssh-keygen -y -f \$key 2>&1; done" +``` + +Note: Coolify's container is Alpine-based — use `sh` not `bash` for `docker exec`. + +--- + +## Workflow Summary + +``` +Before (manual): + edit → build tar → SSH upload → docker load → docker compose up → hope it works + +After (Coolify): + edit → git commit → git push → done (Coolify handles the rest) +``` + +--- + +Built for managing 35+ Docker deployments across two servers via Gitea + Coolify. diff --git a/app/.env.example b/app/.env.example new file mode 100644 index 0000000..21a6713 --- /dev/null +++ b/app/.env.example @@ -0,0 +1,5 @@ +# SSH Credentials for deployment +# Copy this file to .env and fill in your credentials + +SSH_USERNAME=deployer +SSH_PASSWORD=your-password-here diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..800fab1 --- /dev/null +++ b/app/README.md @@ -0,0 +1,150 @@ +# Docker Deployment Manager - Desktop App + +Electron desktop app for managing Docker deployments across multiple Linux servers. + +## Quick Start + +```powershell +# 1. Install dependencies +npm install + +# 2. Configure SSH credentials +cp .env.example .env +# Edit .env with your SSH_USERNAME and SSH_PASSWORD + +# 3. Run the app +npm start + +# For development with DevTools: +npm run dev +``` + +## Features + +### Dashboard +The main screen shows all projects in your `C:\.bucket\Repos.Git` directory that have Docker configurations. + +| Column | Description | +|--------|-------------| +| **Project** | Project name and path | +| **Local** | Click to see what files are present/missing | +| **Deployed** | Whether the project exists on the selected server | +| **Running** | Whether the container is currently running | +| **Diff** | Compare local vs deployed docker-compose.yml | +| **Actions** | Build, Deploy buttons | + +### Local Status (Click for Details) +Shows what files each project has: + +- **Dockerfile** - Container build instructions (you create this manually) +- **docker-compose.yml** - Runtime config (ports, env, volumes) +- **build-image-tar.ps1** - Script to build and package Docker image +- **.dockerignore** - Files to exclude from Docker image + +If files are missing, click the status badge to see what's needed and how to fix it. + +### Server Management +Click "Servers" to add your Linux servers: + +1. **Name**: Friendly name (e.g., "Production Server") +2. **Host**: IP address (e.g., 192.168.69.4) +3. **Username**: SSH username (optional, uses .env by default) + +### Actions + +#### Build +Runs the project's `build-image-tar.ps1` script which: +1. Builds the Docker image: `docker buildx build --platform linux/amd64` +2. Saves to tar: `docker save -o project.tar` + +#### Deploy +1. Uploads the tar file to `~/containers/{project}/` +2. Uploads docker-compose.yml +3. Runs `docker load -i project.tar` +4. Runs `docker compose up -d` + +#### Compare (Diff) +Shows local vs deployed docker-compose.yml side by side. +- **Pull**: Download server version to local (sync your source of truth) +- **Push**: Upload local version to server + +### Init Project +If a project is missing files, the Details modal shows: +- Which files are missing +- Commands to create them + +Click "Run CLI Init" to generate missing files using the CLI tool. + +## Configuration + +### SSH Credentials (.env) +``` +SSH_USERNAME=deployer +SSH_PASSWORD=your-password-here +``` + +### Server Config (deployment-config.json) +Auto-generated when you add servers: +```json +{ + "servers": [ + { "id": "1", "name": "Server 1", "host": "192.168.69.4" }, + { "id": "2", "name": "Server 2", "host": "192.168.69.5" } + ], + "projectsRoot": "C:\\.bucket\\Repos.Git" +} +``` + +## Server Convention + +The app expects this structure on your Linux servers: + +``` +~/containers/ +├── game.justone/ +│ ├── docker-compose.yml +│ ├── .env (optional) +│ ├── data/ (optional) +│ └── game.justone.tar +├── another-project/ +│ └── ... +``` + +## Workflow + +### First Time Setup +1. Create Dockerfile manually for your project (each project is different) +2. Use CLI or app to generate docker-compose.yml and build scripts +3. Add your server(s) in the app +4. Build and deploy + +### Regular Deployment +1. Make code changes +2. Click "Build" to create new tar +3. Click "Deploy" to push to server + +### Checking Differences +If you manually edited something on the server: +1. Select server and click "Scan Server" +2. Click "Compare" on the project +3. Review differences +4. Click "Pull" to update your local copy (source of truth) + +## Files + +``` +app/ +├── main/ +│ ├── index.js # Electron main process + IPC handlers +│ ├── ssh-service.js # SSH/SFTP operations +│ ├── project-scanner.js # Scans local projects for Docker files +│ └── server-scanner.js # Scans remote ~/containers/* via SSH +├── renderer/ +│ ├── index.html # Dashboard UI +│ ├── styles.css # Dark theme +│ └── app.js # Frontend logic +├── preload.js # IPC bridge (contextBridge) +├── .env # SSH credentials (not committed) +├── .env.example # Template +└── package.json +``` diff --git a/app/main/index.js b/app/main/index.js new file mode 100644 index 0000000..bc3caec --- /dev/null +++ b/app/main/index.js @@ -0,0 +1,626 @@ +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const { SSHService } = require('./ssh-service'); +const { ProjectScanner } = require('./project-scanner'); +const { ServerScanner } = require('./server-scanner'); + +let mainWindow; +let config = { + servers: [], + projectsRoot: 'C:\\.bucket\\repos.gitea', + projects: {} +}; + +const CONFIG_PATH = path.join(__dirname, '..', '..', 'deployment-config.json'); +const ENV_PATH = path.join(__dirname, '..', '.env'); + +function loadConfig() { + try { + if (fs.existsSync(CONFIG_PATH)) { + config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + } + } catch (err) { + console.error('Failed to load config:', err); + } +} + +function saveConfig() { + try { + fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); + } catch (err) { + console.error('Failed to save config:', err); + } +} + +function loadEnv() { + try { + if (fs.existsSync(ENV_PATH)) { + const envContent = fs.readFileSync(ENV_PATH, 'utf-8'); + const lines = envContent.split('\n'); + const env = {}; + for (const line of lines) { + const [key, ...valueParts] = line.split('='); + if (key && valueParts.length > 0) { + env[key.trim()] = valueParts.join('=').trim(); + } + } + return env; + } + } catch (err) { + console.error('Failed to load .env:', err); + } + return {}; +} + +// Load project's docker-deployment.json config +function loadProjectDeployConfig(projectPath) { + const configPath = path.join(projectPath, 'docker-deployment.json'); + try { + if (fs.existsSync(configPath)) { + return JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } + } catch (err) { + console.error('Failed to load project config:', err); + } + return null; +} + +// Get list of files to upload for a project +function getUploadFiles(projectPath, projectConfig) { + const projectName = path.basename(projectPath); + + // Default files - always upload these if they exist + const defaultFiles = [ + { local: `${projectName}.tar`, remote: `${projectName}.tar`, type: 'file' }, + { local: 'docker-compose.yml', remote: 'docker-compose.yml', type: 'file' }, + { local: '.env', remote: '.env', type: 'file' } + ]; + + // Additional files from project config + const additionalFiles = projectConfig?.deployment?.uploadFiles || []; + const customFiles = additionalFiles.map(f => { + if (typeof f === 'string') { + // Simple string path - detect if directory + const isDir = f.endsWith('/'); + return { + local: f.replace(/\/$/, ''), + remote: f.replace(/\/$/, ''), + type: isDir ? 'directory' : 'file' + }; + } + return f; // Already an object with local/remote/type + }); + + return [...defaultFiles, ...customFiles]; +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + webPreferences: { + preload: path.join(__dirname, '..', 'preload.js'), + contextIsolation: true, + nodeIntegration: false + }, + title: 'Docker Deployment Manager' + }); + + if (process.argv.includes('--dev')) { + mainWindow.loadURL('http://localhost:5173'); + mainWindow.webContents.openDevTools(); + } else { + mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'dist', 'index.html')); + } +} + +app.whenReady().then(() => { + loadConfig(); + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +// IPC Handlers + +// Get configuration +ipcMain.handle('get-config', () => { + return config; +}); + +// Save configuration +ipcMain.handle('save-config', (event, newConfig) => { + config = { ...config, ...newConfig }; + saveConfig(); + return { success: true }; +}); + +// Get servers +ipcMain.handle('get-servers', () => { + return config.servers || []; +}); + +// Add/update server +ipcMain.handle('save-server', (event, server) => { + const index = config.servers.findIndex(s => s.id === server.id); + if (index >= 0) { + config.servers[index] = server; + } else { + server.id = Date.now().toString(); + config.servers.push(server); + } + saveConfig(); + return { success: true, server }; +}); + +// Delete server +ipcMain.handle('delete-server', (event, serverId) => { + config.servers = config.servers.filter(s => s.id !== serverId); + saveConfig(); + return { success: true }; +}); + +// Scan local projects (deep scan to find nested Dockerfiles) +ipcMain.handle('scan-local-projects', async () => { + const scanner = new ProjectScanner(config.projectsRoot); + const projects = await scanner.scanDeep(2); + return projects; +}); + +// Scan remote server for deployed containers +ipcMain.handle('scan-server', async (event, serverId) => { + const server = config.servers.find(s => s.id === serverId); + if (!server) { + return { error: 'Server not found' }; + } + + const env = loadEnv(); + const sshConfig = { + host: server.host, + username: env.SSH_USERNAME || server.username, + password: env.SSH_PASSWORD || server.password + }; + + const scanner = new ServerScanner(sshConfig); + try { + const deployed = await scanner.scan(); + return { success: true, deployed }; + } catch (err) { + return { error: err.message }; + } +}); + +// Compare local vs deployed +ipcMain.handle('compare-project', async (event, { projectPath, serverId, remotePath }) => { + const server = config.servers.find(s => s.id === serverId); + if (!server) { + return { error: 'Server not found' }; + } + + const env = loadEnv(); + const sshConfig = { + host: server.host, + username: env.SSH_USERNAME || server.username, + password: env.SSH_PASSWORD || server.password + }; + + // Load project config to get additional files to compare + const projectConfig = loadProjectDeployConfig(projectPath); + const additionalFiles = projectConfig?.deployment?.uploadFiles || []; + + const scanner = new ServerScanner(sshConfig); + try { + const diff = await scanner.compareFiles(projectPath, remotePath, additionalFiles); + return { success: true, diff }; + } catch (err) { + return { error: err.message }; + } +}); + +// Build tar for project +ipcMain.handle('build-tar', async (event, projectPath) => { + const { exec } = require('child_process'); + const projectName = path.basename(projectPath); + + return new Promise((resolve) => { + // Check if build-image-tar.ps1 exists + const scriptPath = path.join(projectPath, 'build-image-tar.ps1'); + if (!fs.existsSync(scriptPath)) { + resolve({ error: 'No build-image-tar.ps1 found in project' }); + return; + } + + exec(`powershell -ExecutionPolicy Bypass -File "${scriptPath}"`, { cwd: projectPath }, (error, stdout, stderr) => { + if (error) { + resolve({ error: error.message, stderr }); + } else { + resolve({ success: true, output: stdout }); + } + }); + }); +}); + +// Deploy project to server +ipcMain.handle('deploy-project', async (event, { projectPath, serverId, remotePath }) => { + const server = config.servers.find(s => s.id === serverId); + if (!server) { + return { error: 'Server not found' }; + } + + const env = loadEnv(); + const sshConfig = { + host: server.host, + username: env.SSH_USERNAME || server.username, + password: env.SSH_PASSWORD || server.password + }; + + const ssh = new SSHService(sshConfig); + const projectName = path.basename(projectPath); + + // Sudo prefix for servers that need elevated permissions + const password = env.SSH_PASSWORD || server.password; + const sudoPrefix = server.useSudo ? `echo '${password}' | sudo -S ` : ''; + + // Load project config and get files to upload + const projectConfig = loadProjectDeployConfig(projectPath); + const uploadFiles = getUploadFiles(projectPath, projectConfig); + const uploadedFiles = []; + + try { + await ssh.connect(); + + // Ensure remote directory exists + await ssh.exec(`mkdir -p ${remotePath}`); + + // Delete old tar file with sudo if needed (may be owned by root) + if (server.useSudo) { + await ssh.exec(`echo '${password}' | sudo -S rm -f ${remotePath}/${projectName}.tar 2>/dev/null || true`); + } + + // Upload all configured files + for (const fileSpec of uploadFiles) { + const localPath = path.join(projectPath, fileSpec.local); + + if (!fs.existsSync(localPath)) { + continue; // Skip if file doesn't exist locally + } + + const remoteDest = `${remotePath}/${fileSpec.remote}`; + + if (fileSpec.type === 'directory') { + // Create remote directory and upload all contents + await ssh.exec(`mkdir -p ${remoteDest}`); + + // Get all files in directory recursively + const uploadDir = async (dirPath, remoteDir) => { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const entryLocalPath = path.join(dirPath, entry.name); + const entryRemotePath = `${remoteDir}/${entry.name}`; + + if (entry.isDirectory()) { + await ssh.exec(`mkdir -p ${entryRemotePath}`); + await uploadDir(entryLocalPath, entryRemotePath); + } else { + await ssh.uploadFile(entryLocalPath, entryRemotePath); + } + } + }; + + await uploadDir(localPath, remoteDest); + uploadedFiles.push(`${fileSpec.local}/ (directory)`); + } else { + // Regular file + await ssh.uploadFile(localPath, remoteDest); + uploadedFiles.push(fileSpec.local); + } + } + + // Load image, stop existing container, and start new container + await ssh.exec(`cd ${remotePath} && ${sudoPrefix}docker load -i ${projectName}.tar && ${sudoPrefix}docker compose down 2>/dev/null; ${sudoPrefix}docker compose up -d`); + + // Health check - poll for container status + let healthy = false; + let status = ''; + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2s + try { + status = await ssh.exec(`cd ${remotePath} && ${sudoPrefix}docker compose ps --format "{{.Name}}|{{.Status}}" 2>/dev/null || ${sudoPrefix}docker compose ps`); + if (status.includes('Up') || status.includes('healthy')) { + healthy = true; + break; + } + } catch (err) { + // Ignore errors during health check polling + } + } + + ssh.disconnect(); + return { + success: true, + healthy, + status, + uploadedFiles, + message: healthy ? 'Container started successfully' : 'Container started but health check pending' + }; + } catch (err) { + return { error: err.message }; + } +}); + +// Get docker ps from server +ipcMain.handle('get-running-containers', async (event, serverId) => { + const server = config.servers.find(s => s.id === serverId); + if (!server) { + return { error: 'Server not found' }; + } + + const env = loadEnv(); + const sshConfig = { + host: server.host, + username: env.SSH_USERNAME || server.username, + password: env.SSH_PASSWORD || server.password + }; + + const ssh = new SSHService(sshConfig); + try { + await ssh.connect(); + const result = await ssh.exec('docker ps --format "{{.Names}}|{{.Status}}|{{.Ports}}"'); + ssh.disconnect(); + + const containers = result.split('\n').filter(Boolean).map(line => { + const [name, status, ports] = line.split('|'); + return { name, status, ports }; + }); + + return { success: true, containers }; + } catch (err) { + return { error: err.message }; + } +}); + +// Pull file from server (sync back) +ipcMain.handle('pull-file', async (event, { serverId, remotePath, localPath, isDirectory }) => { + const server = config.servers.find(s => s.id === serverId); + if (!server) { + return { error: 'Server not found' }; + } + + const env = loadEnv(); + const sshConfig = { + host: server.host, + username: env.SSH_USERNAME || server.username, + password: env.SSH_PASSWORD || server.password + }; + + const ssh = new SSHService(sshConfig); + try { + await ssh.connect(); + + if (isDirectory) { + // Pull entire directory recursively + const pullDir = async (remoteDir, localDir) => { + // Ensure local directory exists + if (!fs.existsSync(localDir)) { + fs.mkdirSync(localDir, { recursive: true }); + } + + // List remote directory contents + const result = await ssh.exec(`ls -la ${remoteDir} 2>/dev/null | tail -n +4 || echo ""`); + const lines = result.split('\n').filter(Boolean); + + for (const line of lines) { + const parts = line.split(/\s+/); + if (parts.length < 9) continue; + + const isDir = line.startsWith('d'); + const fileName = parts.slice(8).join(' '); + + if (fileName === '.' || fileName === '..') continue; + + const remoteFilePath = `${remoteDir}/${fileName}`; + const localFilePath = path.join(localDir, fileName); + + if (isDir) { + await pullDir(remoteFilePath, localFilePath); + } else { + await ssh.downloadFile(remoteFilePath, localFilePath); + } + } + }; + + await pullDir(remotePath, localPath); + } else { + // Pull single file + await ssh.downloadFile(remotePath, localPath); + } + + ssh.disconnect(); + return { success: true }; + } catch (err) { + return { error: err.message }; + } +}); + +// Pull multiple files from server +ipcMain.handle('pull-files', async (event, { serverId, files }) => { + const server = config.servers.find(s => s.id === serverId); + if (!server) { + return { error: 'Server not found' }; + } + + const env = loadEnv(); + const sshConfig = { + host: server.host, + username: env.SSH_USERNAME || server.username, + password: env.SSH_PASSWORD || server.password + }; + + const ssh = new SSHService(sshConfig); + const pulled = []; + const errors = []; + + try { + await ssh.connect(); + + for (const file of files) { + try { + if (file.type === 'directory') { + // Pull directory recursively (same logic as above) + const pullDir = async (remoteDir, localDir) => { + if (!fs.existsSync(localDir)) { + fs.mkdirSync(localDir, { recursive: true }); + } + + const result = await ssh.exec(`ls -la ${remoteDir} 2>/dev/null | tail -n +4 || echo ""`); + const lines = result.split('\n').filter(Boolean); + + for (const line of lines) { + const parts = line.split(/\s+/); + if (parts.length < 9) continue; + + const isDir = line.startsWith('d'); + const fileName = parts.slice(8).join(' '); + + if (fileName === '.' || fileName === '..') continue; + + const remoteFilePath = `${remoteDir}/${fileName}`; + const localFilePath = path.join(localDir, fileName); + + if (isDir) { + await pullDir(remoteFilePath, localFilePath); + } else { + await ssh.downloadFile(remoteFilePath, localFilePath); + } + } + }; + + await pullDir(file.remotePath, file.localPath); + pulled.push(file.name); + } else { + // Ensure parent directory exists + const parentDir = path.dirname(file.localPath); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + + await ssh.downloadFile(file.remotePath, file.localPath); + pulled.push(file.name); + } + } catch (err) { + errors.push({ name: file.name, error: err.message }); + } + } + + ssh.disconnect(); + return { success: true, pulled, errors }; + } catch (err) { + return { error: err.message }; + } +}); + +// Initialize project with CLI tool +ipcMain.handle('init-project', async (event, projectPath) => { + const { exec } = require('child_process'); + const cliPath = path.join(__dirname, '..', '..'); + + return new Promise((resolve) => { + const cmd = `node cli/index.js init "${projectPath}" --no-interactive`; + exec(cmd, { cwd: cliPath }, (error, stdout, stderr) => { + if (error) { + resolve({ error: error.message, stderr }); + } else { + resolve({ success: true, output: stdout + (stderr || '') }); + } + }); + }); +}); + +// Get container logs from server +ipcMain.handle('get-container-logs', async (event, { serverId, containerName, remotePath, lines = 100 }) => { + const server = config.servers.find(s => s.id === serverId); + if (!server) { + return { error: 'Server not found' }; + } + + const env = loadEnv(); + const sshConfig = { + host: server.host, + username: env.SSH_USERNAME || server.username, + password: env.SSH_PASSWORD || server.password + }; + + const ssh = new SSHService(sshConfig); + const password = env.SSH_PASSWORD || server.password; + const sudoPrefix = server.useSudo ? `echo '${password}' | sudo -S ` : ''; + + try { + await ssh.connect(); + // Use docker compose logs if remotePath is provided, otherwise docker logs + let logs; + if (remotePath) { + logs = await ssh.exec(`cd ${remotePath} && ${sudoPrefix}docker compose logs --tail ${lines} 2>&1`); + } else { + logs = await ssh.exec(`${sudoPrefix}docker logs ${containerName} --tail ${lines} 2>&1`); + } + ssh.disconnect(); + return { success: true, logs }; + } catch (err) { + return { error: err.message }; + } +}); + +// Open VS Code diff for file comparison +ipcMain.handle('open-vscode-diff', async (event, { localPath, remotePath, serverId, remoteFilePath }) => { + const { exec } = require('child_process'); + const server = config.servers.find(s => s.id === serverId); + if (!server) { + return { error: 'Server not found' }; + } + + const env = loadEnv(); + const sshConfig = { + host: server.host, + username: env.SSH_USERNAME || server.username, + password: env.SSH_PASSWORD || server.password + }; + + const ssh = new SSHService(sshConfig); + + try { + // Download remote file to temp + const tempDir = path.join(require('os').tmpdir(), 'docker-deploy-diff'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + const tempFile = path.join(tempDir, `remote-${path.basename(localPath)}`); + + await ssh.connect(); + await ssh.downloadFile(remoteFilePath, tempFile); + ssh.disconnect(); + + // Open VS Code diff + return new Promise((resolve) => { + exec(`code --diff "${tempFile}" "${localPath}"`, (error) => { + if (error) { + resolve({ error: error.message }); + } else { + resolve({ success: true }); + } + }); + }); + } catch (err) { + return { error: err.message }; + } +}); diff --git a/app/main/project-scanner.js b/app/main/project-scanner.js new file mode 100644 index 0000000..dc52544 --- /dev/null +++ b/app/main/project-scanner.js @@ -0,0 +1,123 @@ +const fs = require('fs'); +const path = require('path'); + +class ProjectScanner { + constructor(rootPath) { + this.rootPath = rootPath; + } + + async scan() { + const projects = []; + + try { + const entries = fs.readdirSync(this.rootPath, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const projectPath = path.join(this.rootPath, entry.name); + const projectInfo = this.analyzeProject(projectPath, entry.name); + + if (projectInfo) { + projects.push(projectInfo); + } + } + } catch (err) { + console.error('Failed to scan projects:', err); + } + + return projects; + } + + analyzeProject(projectPath, name) { + const hasDockerfile = fs.existsSync(path.join(projectPath, 'Dockerfile')); + const hasDockerCompose = fs.existsSync(path.join(projectPath, 'docker-compose.yml')); + const hasBuildScript = fs.existsSync(path.join(projectPath, 'build-image-tar.ps1')); + const hasDeployScript = fs.existsSync(path.join(projectPath, 'deploy-docker-auto.ps1')); + const hasDeploymentConfig = fs.existsSync(path.join(projectPath, 'docker-deployment.json')); + const hasDockerIgnore = fs.existsSync(path.join(projectPath, '.dockerignore')); + + // Find tar files + let tarFile = null; + try { + const files = fs.readdirSync(projectPath); + tarFile = files.find(f => f.endsWith('.tar')); + } catch (err) { + // ignore + } + + // Read deployment config if exists + let deploymentConfig = null; + if (hasDeploymentConfig) { + try { + deploymentConfig = JSON.parse(fs.readFileSync(path.join(projectPath, 'docker-deployment.json'), 'utf-8')); + } catch (err) { + // ignore + } + } + + // Determine docker status + let dockerStatus = 'none'; + if (hasDockerfile && hasDockerCompose) { + dockerStatus = 'configured'; + } else if (hasDockerfile || hasDockerCompose) { + dockerStatus = 'partial'; + } + + return { + name, + path: projectPath, + hasDockerfile, + hasDockerCompose, + hasBuildScript, + hasDeployScript, + hasDeploymentConfig, + hasDockerIgnore, + tarFile, + deploymentConfig, + dockerStatus, + serverId: deploymentConfig?.deployment?.serverId || null, + remotePath: deploymentConfig?.deployment?.targetPath || `~/containers/${name}` + }; + } + + // Scan subdirectories too (for monorepos like AudioSphere) + async scanDeep(maxDepth = 2) { + const projects = []; + await this._scanRecursive(this.rootPath, projects, 0, maxDepth); + return projects; + } + + async _scanRecursive(currentPath, projects, depth, maxDepth) { + if (depth > maxDepth) return; + + try { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + + // Check if this directory has a Dockerfile + const hasDockerfile = entries.some(e => e.name === 'Dockerfile'); + if (hasDockerfile) { + const name = path.relative(this.rootPath, currentPath).replace(/\\/g, '/'); + const projectInfo = this.analyzeProject(currentPath, name); + if (projectInfo) { + projects.push(projectInfo); + } + } + + // Recurse into subdirectories + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.')) continue; + if (entry.name === 'node_modules') continue; + if (entry.name === 'dist') continue; + if (entry.name === 'build') continue; + + await this._scanRecursive(path.join(currentPath, entry.name), projects, depth + 1, maxDepth); + } + } catch (err) { + // ignore permission errors etc + } + } +} + +module.exports = { ProjectScanner }; diff --git a/app/main/server-scanner.js b/app/main/server-scanner.js new file mode 100644 index 0000000..7ef9c80 --- /dev/null +++ b/app/main/server-scanner.js @@ -0,0 +1,280 @@ +const { SSHService } = require('./ssh-service'); +const fs = require('fs'); +const path = require('path'); + +class ServerScanner { + constructor(sshConfig) { + this.sshConfig = sshConfig; + this.ssh = new SSHService(sshConfig); + } + + async scan() { + const deployed = []; + + try { + await this.ssh.connect(); + + // List directories in ~/containers + const result = await this.ssh.exec('ls -1 ~/containers 2>/dev/null || echo ""'); + const projectDirs = result.split('\n').filter(Boolean); + + for (const dir of projectDirs) { + const remotePath = `~/containers/${dir}`; + const info = await this.getProjectInfo(remotePath, dir); + if (info) { + deployed.push(info); + } + } + + this.ssh.disconnect(); + } catch (err) { + console.error('Failed to scan server:', err); + throw err; + } + + return deployed; + } + + async getProjectInfo(remotePath, name) { + try { + // Check what files exist + const filesResult = await this.ssh.exec(`ls -la ${remotePath} 2>/dev/null || echo ""`); + + const hasDockerCompose = filesResult.includes('docker-compose.yml'); + const hasEnv = filesResult.includes('.env'); + const hasData = filesResult.includes('data'); + const tarMatch = filesResult.match(/(\S+\.tar)/); + const tarFile = tarMatch ? tarMatch[1] : null; + + // Get docker-compose.yml content if exists + let dockerComposeContent = null; + if (hasDockerCompose) { + try { + dockerComposeContent = await this.ssh.exec(`cat ${remotePath}/docker-compose.yml 2>/dev/null`); + } catch (err) { + // ignore + } + } + + return { + name, + remotePath, + hasDockerCompose, + hasEnv, + hasData, + tarFile, + dockerComposeContent + }; + } catch (err) { + return null; + } + } + + async compareFiles(localProjectPath, remotePath, additionalFiles = []) { + const diff = { + dockerCompose: { status: 'unknown', localContent: null, remoteContent: null }, + env: { status: 'unknown' }, + data: { status: 'unknown' }, + // New: array of all compared files for UI + files: [] + }; + + try { + await this.ssh.connect(); + + // Compare docker-compose.yml + const localComposePath = path.join(localProjectPath, 'docker-compose.yml'); + if (fs.existsSync(localComposePath)) { + diff.dockerCompose.localContent = fs.readFileSync(localComposePath, 'utf-8'); + + try { + diff.dockerCompose.remoteContent = await this.ssh.exec(`cat ${remotePath}/docker-compose.yml 2>/dev/null`); + + if (diff.dockerCompose.localContent.trim() === diff.dockerCompose.remoteContent.trim()) { + diff.dockerCompose.status = 'match'; + } else { + diff.dockerCompose.status = 'different'; + } + } catch (err) { + diff.dockerCompose.status = 'remote-missing'; + } + } else { + diff.dockerCompose.status = 'local-missing'; + } + + // Add to files array + diff.files.push({ + name: 'docker-compose.yml', + type: 'file', + status: diff.dockerCompose.status, + localContent: diff.dockerCompose.localContent, + remoteContent: diff.dockerCompose.remoteContent, + localPath: localComposePath, + remotePath: `${remotePath}/docker-compose.yml` + }); + + // Check .env exists on both and compare content + const localEnvPath = path.join(localProjectPath, '.env'); + const hasLocalEnv = fs.existsSync(localEnvPath); + + // Read local .env content + if (hasLocalEnv) { + diff.env.localContent = fs.readFileSync(localEnvPath, 'utf-8'); + } + + // Check and read remote .env + try { + await this.ssh.exec(`test -f ${remotePath}/.env`); + diff.env.hasRemote = true; + try { + diff.env.remoteContent = await this.ssh.exec(`cat ${remotePath}/.env 2>/dev/null`); + } catch (err) { + diff.env.remoteContent = null; + } + } catch (err) { + diff.env.hasRemote = false; + } + + diff.env.hasLocal = hasLocalEnv; + + // Determine status based on content comparison + if (hasLocalEnv && diff.env.hasRemote) { + if (diff.env.localContent && diff.env.remoteContent) { + diff.env.status = diff.env.localContent.trim() === diff.env.remoteContent.trim() + ? 'match' : 'different'; + } else { + diff.env.status = 'both-exist'; + } + } else if (hasLocalEnv) { + diff.env.status = 'remote-missing'; + } else if (diff.env.hasRemote) { + diff.env.status = 'local-missing'; + } else { + diff.env.status = 'neither'; + } + + // Add to files array + diff.files.push({ + name: '.env', + type: 'file', + status: diff.env.status, + localContent: diff.env.localContent, + remoteContent: diff.env.remoteContent, + localPath: localEnvPath, + remotePath: `${remotePath}/.env`, + sensitive: true // Mark as sensitive for masking in UI + }); + + // Check data directory + const localDataPath = path.join(localProjectPath, 'data'); + const hasLocalData = fs.existsSync(localDataPath); + + try { + await this.ssh.exec(`test -d ${remotePath}/data`); + diff.data.hasRemote = true; + } catch (err) { + diff.data.hasRemote = false; + } + + diff.data.hasLocal = hasLocalData; + diff.data.status = hasLocalData && diff.data.hasRemote ? 'both-exist' : + hasLocalData ? 'remote-missing' : + diff.data.hasRemote ? 'local-missing' : 'neither'; + + // Add to files array + diff.files.push({ + name: 'data/', + type: 'directory', + status: diff.data.status, + localPath: localDataPath, + remotePath: `${remotePath}/data` + }); + + // Compare additional files from project config + for (const fileSpec of additionalFiles) { + const fileName = typeof fileSpec === 'string' ? fileSpec : fileSpec.local; + const isDir = fileName.endsWith('/'); + const cleanName = fileName.replace(/\/$/, ''); + const localFilePath = path.join(localProjectPath, cleanName); + const remoteFilePath = `${remotePath}/${cleanName}`; + + // Skip if already compared (default files) + if (['docker-compose.yml', '.env', 'data', 'data/'].includes(fileName)) { + continue; + } + + const fileInfo = { + name: fileName, + type: isDir ? 'directory' : 'file', + status: 'unknown', + localPath: localFilePath, + remotePath: remoteFilePath + }; + + const hasLocal = fs.existsSync(localFilePath); + + if (isDir) { + // Directory comparison + let hasRemote = false; + try { + await this.ssh.exec(`test -d ${remoteFilePath}`); + hasRemote = true; + } catch (err) { + hasRemote = false; + } + + fileInfo.status = hasLocal && hasRemote ? 'both-exist' : + hasLocal ? 'remote-missing' : + hasRemote ? 'local-missing' : 'neither'; + } else { + // File comparison with content + if (hasLocal) { + try { + fileInfo.localContent = fs.readFileSync(localFilePath, 'utf-8'); + } catch (err) { + // Binary file or read error + fileInfo.localContent = null; + } + } + + try { + fileInfo.remoteContent = await this.ssh.exec(`cat ${remoteFilePath} 2>/dev/null`); + + if (hasLocal && fileInfo.localContent !== null && fileInfo.remoteContent !== null) { + fileInfo.status = fileInfo.localContent.trim() === fileInfo.remoteContent.trim() + ? 'match' : 'different'; + } else if (hasLocal) { + fileInfo.status = 'different'; // Local exists but can't compare content + } else { + fileInfo.status = 'local-missing'; + } + } catch (err) { + fileInfo.status = hasLocal ? 'remote-missing' : 'neither'; + } + } + + diff.files.push(fileInfo); + } + + this.ssh.disconnect(); + } catch (err) { + console.error('Failed to compare files:', err); + throw err; + } + + return diff; + } + + async getRemoteFileContent(remotePath) { + try { + await this.ssh.connect(); + const content = await this.ssh.exec(`cat ${remotePath} 2>/dev/null`); + this.ssh.disconnect(); + return content; + } catch (err) { + throw err; + } + } +} + +module.exports = { ServerScanner }; diff --git a/app/main/ssh-service.js b/app/main/ssh-service.js new file mode 100644 index 0000000..778cefd --- /dev/null +++ b/app/main/ssh-service.js @@ -0,0 +1,196 @@ +const { Client } = require('ssh2'); +const fs = require('fs'); +const path = require('path'); + +class SSHService { + constructor(config) { + this.config = config; + this.client = null; + } + + connect() { + return new Promise((resolve, reject) => { + this.client = new Client(); + + this.client.on('ready', () => { + resolve(); + }); + + this.client.on('error', (err) => { + reject(err); + }); + + this.client.connect({ + host: this.config.host, + port: this.config.port || 22, + username: this.config.username, + password: this.config.password + }); + }); + } + + disconnect() { + if (this.client) { + this.client.end(); + this.client = null; + } + } + + exec(command) { + return new Promise((resolve, reject) => { + if (!this.client) { + reject(new Error('Not connected')); + return; + } + + this.client.exec(command, (err, stream) => { + if (err) { + reject(err); + return; + } + + let output = ''; + let errorOutput = ''; + + stream.on('close', (code) => { + if (code !== 0 && errorOutput) { + reject(new Error(errorOutput)); + } else { + resolve(output); + } + }); + + stream.on('data', (data) => { + output += data.toString(); + }); + + stream.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + }); + }); + } + + uploadFile(localPath, remotePath) { + return new Promise((resolve, reject) => { + if (!this.client) { + reject(new Error('Not connected')); + return; + } + + this.client.sftp((err, sftp) => { + if (err) { + reject(err); + return; + } + + const readStream = fs.createReadStream(localPath); + const writeStream = sftp.createWriteStream(remotePath); + + writeStream.on('close', () => { + resolve(); + }); + + writeStream.on('error', (err) => { + reject(err); + }); + + readStream.pipe(writeStream); + }); + }); + } + + downloadFile(remotePath, localPath) { + return new Promise((resolve, reject) => { + if (!this.client) { + reject(new Error('Not connected')); + return; + } + + this.client.sftp((err, sftp) => { + if (err) { + reject(err); + return; + } + + // Ensure local directory exists + const localDir = path.dirname(localPath); + if (!fs.existsSync(localDir)) { + fs.mkdirSync(localDir, { recursive: true }); + } + + const readStream = sftp.createReadStream(remotePath); + const writeStream = fs.createWriteStream(localPath); + + writeStream.on('close', () => { + resolve(); + }); + + writeStream.on('error', (err) => { + reject(err); + }); + + readStream.on('error', (err) => { + reject(err); + }); + + readStream.pipe(writeStream); + }); + }); + } + + readRemoteFile(remotePath) { + return new Promise((resolve, reject) => { + if (!this.client) { + reject(new Error('Not connected')); + return; + } + + this.client.sftp((err, sftp) => { + if (err) { + reject(err); + return; + } + + sftp.readFile(remotePath, 'utf-8', (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + }); + } + + listDirectory(remotePath) { + return new Promise((resolve, reject) => { + if (!this.client) { + reject(new Error('Not connected')); + return; + } + + this.client.sftp((err, sftp) => { + if (err) { + reject(err); + return; + } + + sftp.readdir(remotePath, (err, list) => { + if (err) { + reject(err); + } else { + resolve(list.map(item => ({ + name: item.filename, + isDirectory: item.attrs.isDirectory(), + size: item.attrs.size, + mtime: item.attrs.mtime + }))); + } + }); + }); + }); + } +} + +module.exports = { SSHService }; diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..7c0e0ba --- /dev/null +++ b/app/package.json @@ -0,0 +1,22 @@ +{ + "name": "docker-deployment-manager-app", + "version": "1.0.0", + "description": "Desktop app for managing Docker deployments across multiple servers", + "main": "main/index.js", + "scripts": { + "start": "electron .", + "dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"", + "dev:vite": "cd renderer && npx vite", + "dev:electron": "wait-on http://localhost:5173 && electron . --dev", + "build": "cd renderer && npx vite build", + "preview": "npm run build && electron ." + }, + "dependencies": { + "ssh2": "^1.16.0" + }, + "devDependencies": { + "electron": "^33.0.0", + "concurrently": "^9.0.0", + "wait-on": "^8.0.0" + } +} diff --git a/app/preload.js b/app/preload.js new file mode 100644 index 0000000..47bf5f3 --- /dev/null +++ b/app/preload.js @@ -0,0 +1,33 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('api', { + // Config + getConfig: () => ipcRenderer.invoke('get-config'), + saveConfig: (config) => ipcRenderer.invoke('save-config', config), + + // Servers + getServers: () => ipcRenderer.invoke('get-servers'), + saveServer: (server) => ipcRenderer.invoke('save-server', server), + deleteServer: (serverId) => ipcRenderer.invoke('delete-server', serverId), + + // Scanning + scanLocalProjects: () => ipcRenderer.invoke('scan-local-projects'), + scanServer: (serverId) => ipcRenderer.invoke('scan-server', serverId), + getRunningContainers: (serverId) => ipcRenderer.invoke('get-running-containers', serverId), + + // Comparison + compareProject: (data) => ipcRenderer.invoke('compare-project', data), + + // Actions + buildTar: (projectPath) => ipcRenderer.invoke('build-tar', projectPath), + deployProject: (data) => ipcRenderer.invoke('deploy-project', data), + pullFile: (data) => ipcRenderer.invoke('pull-file', data), + pullFiles: (data) => ipcRenderer.invoke('pull-files', data), + initProject: (projectPath) => ipcRenderer.invoke('init-project', projectPath), + + // Logs + getContainerLogs: (data) => ipcRenderer.invoke('get-container-logs', data), + + // VS Code integration + openVSCodeDiff: (data) => ipcRenderer.invoke('open-vscode-diff', data) +}); diff --git a/app/renderer-legacy/app.js b/app/renderer-legacy/app.js new file mode 100644 index 0000000..773ff02 --- /dev/null +++ b/app/renderer-legacy/app.js @@ -0,0 +1,922 @@ +// State +let state = { + servers: [], + selectedServerId: null, + localProjects: [], + deployedProjects: [], + runningContainers: [], + currentDiffProject: null, + currentDetailsProject: null, + currentDeployProject: null, + currentDeployDiff: null, + currentLogsProject: null +}; + +// DOM Elements +const serverSelect = document.getElementById('server-select'); +const btnScanServer = document.getElementById('btn-scan-server'); +const serverStatus = document.getElementById('server-status'); +const projectsBody = document.getElementById('projects-body'); +const btnRefresh = document.getElementById('btn-refresh'); +const btnSettings = document.getElementById('btn-settings'); + +// Modals +const serverModal = document.getElementById('server-modal'); +const diffModal = document.getElementById('diff-modal'); +const logModal = document.getElementById('log-modal'); +const detailsModal = document.getElementById('details-modal'); +const deployConfirmModal = document.getElementById('deploy-confirm-modal'); +const logsModal = document.getElementById('logs-modal'); + +// Initialize +async function init() { + await loadServers(); + await loadLocalProjects(); + setupEventListeners(); +} + +function setupEventListeners() { + btnRefresh.addEventListener('click', () => loadLocalProjects()); + btnSettings.addEventListener('click', () => showServerModal()); + btnScanServer.addEventListener('click', () => scanServer()); + + serverSelect.addEventListener('change', (e) => { + state.selectedServerId = e.target.value || null; + btnScanServer.disabled = !state.selectedServerId; + state.deployedProjects = []; + state.runningContainers = []; + renderProjects(); + }); + + // Modal close buttons + document.getElementById('close-server-modal').addEventListener('click', () => hideModal(serverModal)); + document.getElementById('close-diff-modal').addEventListener('click', () => hideModal(diffModal)); + document.getElementById('close-log-modal').addEventListener('click', () => hideModal(logModal)); + document.getElementById('close-details-modal').addEventListener('click', () => hideModal(detailsModal)); + document.getElementById('close-deploy-confirm').addEventListener('click', () => hideModal(deployConfirmModal)); + document.getElementById('close-logs-modal').addEventListener('click', () => hideModal(logsModal)); + + // Server form + document.getElementById('btn-save-server').addEventListener('click', saveServer); + + // Details modal - init project button + document.getElementById('btn-init-project').addEventListener('click', initCurrentProject); + + // Deploy confirmation modal buttons + document.getElementById('btn-deploy-continue').addEventListener('click', executeDeployment); + document.getElementById('btn-deploy-abort').addEventListener('click', () => hideModal(deployConfirmModal)); + document.getElementById('btn-deploy-vscode').addEventListener('click', openVSCodeDiff); + + // Logs modal - refresh button + document.getElementById('btn-refresh-logs').addEventListener('click', refreshLogs); +} + +// Load servers +async function loadServers() { + state.servers = await window.api.getServers(); + renderServerSelect(); +} + +function renderServerSelect() { + serverSelect.innerHTML = ''; + for (const server of state.servers) { + const option = document.createElement('option'); + option.value = server.id; + option.textContent = `${server.name} (${server.host})`; + serverSelect.appendChild(option); + } +} + +// Load local projects +async function loadLocalProjects() { + projectsBody.innerHTML = 'Scanning local projects...'; + + try { + state.localProjects = await window.api.scanLocalProjects(); + renderProjects(); + } catch (err) { + projectsBody.innerHTML = `Error: ${err.message}`; + } +} + +// Scan remote server +async function scanServer() { + if (!state.selectedServerId) return; + + serverStatus.textContent = 'Connecting...'; + serverStatus.className = 'status-badge'; + + try { + // Get deployed projects + const deployedResult = await window.api.scanServer(state.selectedServerId); + if (deployedResult.error) { + throw new Error(deployedResult.error); + } + state.deployedProjects = deployedResult.deployed || []; + + // Get running containers + const containersResult = await window.api.getRunningContainers(state.selectedServerId); + if (containersResult.success) { + state.runningContainers = containersResult.containers || []; + } + + serverStatus.textContent = `Connected (${state.deployedProjects.length} deployed)`; + serverStatus.className = 'status-badge connected'; + renderProjects(); + } catch (err) { + serverStatus.textContent = `Error: ${err.message}`; + serverStatus.className = 'status-badge error'; + } +} + +// Render projects table +function renderProjects() { + if (state.localProjects.length === 0) { + projectsBody.innerHTML = 'No projects found with Dockerfiles'; + return; + } + + // Merge local and deployed data + const projectMap = new Map(); + + // Add local projects + for (const project of state.localProjects) { + projectMap.set(project.name, { + ...project, + deployed: null, + running: null + }); + } + + // Match with deployed projects + for (const deployed of state.deployedProjects) { + if (projectMap.has(deployed.name)) { + projectMap.get(deployed.name).deployed = deployed; + } else { + // Project exists on server but not locally + projectMap.set(deployed.name, { + name: deployed.name, + path: null, + hasDockerfile: false, + hasDockerCompose: false, + dockerStatus: 'remote-only', + deployed + }); + } + } + + // Match with running containers + for (const container of state.runningContainers) { + // Try to match container name with project name + for (const [name, project] of projectMap) { + if (container.name.includes(name) || name.includes(container.name)) { + project.running = container; + break; + } + } + } + + // Render table rows + projectsBody.innerHTML = ''; + + for (const [name, project] of projectMap) { + const row = document.createElement('tr'); + row.innerHTML = ` + + ${name} + ${project.path ? `${project.path}` : ''} + + + ${renderLocalStatus(project)} + + + ${renderDeployedStatus(project)} + + + ${renderRunningStatus(project)} + + + ${renderDiffStatus(project)} + + + ${renderActions(project)} + + `; + projectsBody.appendChild(row); + } +} + +function renderLocalStatus(project) { + if (!project.path) { + return 'Not local'; + } + + // Count missing files + const requiredFiles = getRequiredFiles(project); + const missingCount = requiredFiles.filter(f => !f.present).length; + + if (missingCount === 0) { + return `Ready`; + } else if (missingCount <= 2) { + return `${missingCount} missing`; + } else { + return `${missingCount} missing`; + } +} + +function renderDeployedStatus(project) { + if (!state.selectedServerId) { + return 'Select server'; + } + + if (project.deployed) { + return 'Deployed'; + } else { + return 'Not deployed'; + } +} + +function renderRunningStatus(project) { + if (!state.selectedServerId) { + return '-'; + } + + if (project.running) { + return `${project.running.status.split(' ')[0]}`; + } else if (project.deployed) { + return 'Stopped'; + } else { + return '-'; + } +} + +function renderDiffStatus(project) { + if (!project.deployed || !project.path) { + return 'N/A'; + } + + // If we have deployed content, we can compare + if (project.deployed.dockerComposeContent && project.hasDockerCompose) { + return ``; + } + + return '?'; +} + +function renderActions(project) { + const actions = []; + + if (project.path && project.hasDockerfile) { + actions.push(``); + } + + if (project.path && state.selectedServerId) { + actions.push(``); + } + + // Add logs button for running containers + if (project.running && state.selectedServerId) { + actions.push(``); + } + + return actions.join(' '); +} + +// Actions +async function buildTar(projectPath) { + showLog('Building Docker image and tar...\n'); + + const result = await window.api.buildTar(projectPath); + + if (result.error) { + appendLog(`\nError: ${result.error}\n${result.stderr || ''}`); + } else { + appendLog(result.output); + appendLog('\n\nBuild complete!'); + } +} + +async function deployProject(projectName) { + const project = state.localProjects.find(p => p.name === projectName); + if (!project || !state.selectedServerId) return; + + state.currentDeployProject = project; + + // Step 1: Compare files first + showLog(`Checking for differences before deploy...`); + + const comparison = await window.api.compareProject({ + projectPath: project.path, + serverId: state.selectedServerId, + remotePath: project.remotePath || `~/containers/${projectName}` + }); + + hideModal(logModal); + + if (comparison.error) { + showLog(`Error comparing files: ${comparison.error}`); + return; + } + + state.currentDeployDiff = comparison.diff; + + // Step 2: Check if there are differences + const composeStatus = comparison.diff.dockerCompose.status; + const envStatus = comparison.diff.env.status; + const hasDiff = composeStatus === 'different' || envStatus === 'different'; + const isNewDeploy = composeStatus === 'remote-missing' && envStatus !== 'different'; + + if (isNewDeploy) { + // New deployment - no confirmation needed + await executeDeployment(); + return; + } + + if (hasDiff) { + // Show confirmation modal with differences + showDeployConfirmModal(project, comparison.diff); + return; + } + + // No differences - proceed directly + await executeDeployment(); +} + +function showDeployConfirmModal(project, diff) { + document.getElementById('deploy-confirm-project').textContent = project.name; + + // Check all files for differences + const filesWithDiff = (diff.files || []).filter(f => + f.status === 'different' || f.status === 'local-missing' + ); + const hasDiff = filesWithDiff.length > 0; + + // Summary + const summaryEl = document.getElementById('deploy-diff-summary'); + if (hasDiff) { + summaryEl.className = 'deploy-diff-summary has-diff'; + summaryEl.innerHTML = ` + Differences found! ${filesWithDiff.length} file(s) differ from deployed versions. +
Continuing will overwrite the remote files. + ${filesWithDiff.some(f => f.status === 'local-missing') ? + '
Warning: Some files exist on server but not locally.' : ''} + `; + } else { + summaryEl.className = 'deploy-diff-summary no-diff'; + summaryEl.innerHTML = `No differences found. Local and remote files match.`; + } + + // Details - show all files with their status + const detailsEl = document.getElementById('deploy-diff-details'); + detailsEl.innerHTML = ''; + + // Show all compared files + const files = diff.files || []; + for (const file of files) { + const statusBadge = getStatusBadge(file.status); + const fileIndex = files.indexOf(file); + + if (file.status === 'different') { + // Show diff for files that are different + const content = file.sensitive ? + { local: maskEnvContent(file.localContent), remote: maskEnvContent(file.remoteContent) } : + { local: file.localContent, remote: file.remoteContent }; + + detailsEl.innerHTML += ` +
+

+ ${file.name} ${statusBadge} + +

+
+
+
Local
+
${escapeHtml(content.local || '(empty)')}
+
+
+
Remote
+
${escapeHtml(content.remote || '(empty)')}
+
+
+
+ `; + } else if (file.status === 'local-missing' && file.remoteContent) { + // File exists on server but not locally + detailsEl.innerHTML += ` +
+

+ ${file.name} ${statusBadge} + +

+
+
+
Local
+
(not found)
+
+
+
Remote
+
${escapeHtml(file.sensitive ? maskEnvContent(file.remoteContent) : file.remoteContent)}
+
+
+
+ `; + } else if (file.type === 'directory' && file.status !== 'neither') { + // Directory status + detailsEl.innerHTML += ` +
+

+ ${file.name} ${statusBadge} + ${file.status === 'local-missing' || file.status === 'both-exist' ? + `` : ''} +

+
+ `; + } + } + + // Add "Pull All Different" button if there are pullable files + const pullableFiles = files.filter(f => + (f.status === 'different' || f.status === 'local-missing') && f.remoteContent + ); + if (pullableFiles.length > 0) { + detailsEl.innerHTML += ` +
+ +
+ `; + } + + showModal(deployConfirmModal); +} + +function getStatusBadge(status) { + switch (status) { + case 'match': + return 'Match'; + case 'different': + return 'Different'; + case 'remote-missing': + return 'Not on server'; + case 'local-missing': + return 'Only on server'; + case 'both-exist': + return 'Both exist'; + case 'neither': + return 'Neither'; + default: + return 'Unknown'; + } +} + +// Pull a single file from server +window.pullSingleFile = async function(fileIndex) { + const diff = state.currentDeployDiff; + const project = state.currentDeployProject; + if (!diff || !project || !diff.files[fileIndex]) return; + + const file = diff.files[fileIndex]; + showLog(`Pulling ${file.name} from server...`); + + const result = await window.api.pullFile({ + serverId: state.selectedServerId, + remotePath: file.remotePath, + localPath: file.localPath, + isDirectory: file.type === 'directory' + }); + + if (result.error) { + appendLog(`\nError: ${result.error}`); + } else { + appendLog(`\nPulled ${file.name} successfully!`); + // Refresh comparison + hideModal(logModal); + await deployProject(project.name); + } +}; + +// Pull all different files from server +window.pullAllDifferent = async function() { + const diff = state.currentDeployDiff; + const project = state.currentDeployProject; + if (!diff || !project) return; + + const filesToPull = (diff.files || []).filter(f => + (f.status === 'different' || f.status === 'local-missing') && + (f.remoteContent || f.type === 'directory') + ); + + if (filesToPull.length === 0) { + alert('No files to pull'); + return; + } + + showLog(`Pulling ${filesToPull.length} files from server...\n`); + + const result = await window.api.pullFiles({ + serverId: state.selectedServerId, + files: filesToPull + }); + + if (result.error) { + appendLog(`\nError: ${result.error}`); + } else { + if (result.pulled && result.pulled.length > 0) { + appendLog(`\nPulled: ${result.pulled.join(', ')}`); + } + if (result.errors && result.errors.length > 0) { + appendLog(`\nErrors:`); + for (const err of result.errors) { + appendLog(`\n ${err.name}: ${err.error}`); + } + } + appendLog('\n\nDone! Refreshing...'); + + // Refresh + hideModal(logModal); + hideModal(deployConfirmModal); + await loadLocalProjects(); + } +}; + +function maskEnvContent(content) { + if (!content) return null; + // Mask values that look like passwords/secrets + return content.replace(/^([A-Z_]+PASSWORD|[A-Z_]+SECRET|[A-Z_]+KEY|[A-Z_]+TOKEN)=(.+)$/gm, '$1=****'); +} + +function escapeHtml(text) { + if (!text) return ''; + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +async function executeDeployment() { + const project = state.currentDeployProject; + if (!project) return; + + hideModal(deployConfirmModal); + showLog(`Deploying ${project.name}...\n`); + + const result = await window.api.deployProject({ + projectPath: project.path, + serverId: state.selectedServerId, + remotePath: project.remotePath || `~/containers/${project.name}` + }); + + if (result.error) { + appendLog(`\nError: ${result.error}`); + } else { + // Show uploaded files + if (result.uploadedFiles && result.uploadedFiles.length > 0) { + appendLog(`\nUploaded files:\n - ${result.uploadedFiles.join('\n - ')}`); + } + + appendLog(`\n\n${result.message || 'Deployment complete!'}`); + if (result.status) { + appendLog(`\n\nContainer status:\n${result.status}`); + } + if (result.healthy) { + appendLog('\n\nHealth check: PASSED'); + } else { + appendLog('\n\nHealth check: PENDING (container may still be starting)'); + } + // Refresh server state + await scanServer(); + } + + state.currentDeployProject = null; + state.currentDeployDiff = null; +} + +async function openVSCodeDiff() { + const project = state.currentDeployProject; + const diff = state.currentDeployDiff; + if (!project || !diff) return; + + // Open docker-compose.yml diff in VS Code + if (diff.dockerCompose.status === 'different') { + const result = await window.api.openVSCodeDiff({ + localPath: `${project.path}/docker-compose.yml`, + serverId: state.selectedServerId, + remoteFilePath: `${project.remotePath || '~/containers/' + project.name}/docker-compose.yml` + }); + + if (result.error) { + alert(`Error opening VS Code: ${result.error}`); + } + } +} + +async function showDiff(projectName) { + const project = state.localProjects.find(p => p.name === projectName); + if (!project) return; + + state.currentDiffProject = project; + document.getElementById('diff-project-name').textContent = projectName; + + const result = await window.api.compareProject({ + projectPath: project.path, + serverId: state.selectedServerId, + remotePath: project.remotePath || `~/containers/${projectName}` + }); + + if (result.error) { + alert(`Error comparing: ${result.error}`); + return; + } + + const diff = result.diff; + + // docker-compose status + const statusEl = document.getElementById('diff-compose-status'); + if (diff.dockerCompose.status === 'match') { + statusEl.innerHTML = 'Files match'; + } else if (diff.dockerCompose.status === 'different') { + statusEl.innerHTML = 'Files differ'; + } else if (diff.dockerCompose.status === 'remote-missing') { + statusEl.innerHTML = 'Not deployed'; + } else if (diff.dockerCompose.status === 'local-missing') { + statusEl.innerHTML = 'No local file'; + } else { + statusEl.innerHTML = 'Unknown'; + } + + document.getElementById('diff-compose-local').textContent = diff.dockerCompose.localContent || '(not found)'; + document.getElementById('diff-compose-remote').textContent = diff.dockerCompose.remoteContent || '(not found)'; + + // Wire up pull/push buttons + document.getElementById('btn-pull-compose').onclick = () => pullCompose(project); + document.getElementById('btn-push-compose').onclick = () => pushCompose(project); + + showModal(diffModal); +} + +async function pullCompose(project) { + const result = await window.api.pullFile({ + serverId: state.selectedServerId, + remotePath: `${project.remotePath || '~/containers/' + project.name}/docker-compose.yml`, + localPath: `${project.path}/docker-compose.yml` + }); + + if (result.error) { + alert(`Error: ${result.error}`); + } else { + alert('Pulled docker-compose.yml from server'); + hideModal(diffModal); + loadLocalProjects(); + } +} + +async function pushCompose(project) { + // Deploy just the compose file + await deployProject(project.name); + hideModal(diffModal); +} + +// Server management +function showServerModal() { + renderServerList(); + clearServerForm(); + showModal(serverModal); +} + +function renderServerList() { + const listEl = document.getElementById('server-list'); + listEl.innerHTML = ''; + + for (const server of state.servers) { + const item = document.createElement('div'); + item.className = 'server-item'; + item.innerHTML = ` +
+ ${server.name} + ${server.host} + ${server.useSudo ? '
sudo
' : ''} +
+
+ + +
+ `; + listEl.appendChild(item); + } +} + +function clearServerForm() { + document.getElementById('server-id').value = ''; + document.getElementById('server-name').value = ''; + document.getElementById('server-host').value = ''; + document.getElementById('server-username').value = ''; + document.getElementById('server-use-sudo').checked = false; +} + +window.editServer = function(serverId) { + const server = state.servers.find(s => s.id === serverId); + if (!server) return; + + document.getElementById('server-id').value = server.id; + document.getElementById('server-name').value = server.name; + document.getElementById('server-host').value = server.host; + document.getElementById('server-username').value = server.username || ''; + document.getElementById('server-use-sudo').checked = server.useSudo || false; +}; + +window.deleteServer = async function(serverId) { + if (!confirm('Delete this server?')) return; + + await window.api.deleteServer(serverId); + await loadServers(); + renderServerList(); +}; + +async function saveServer() { + const server = { + id: document.getElementById('server-id').value || undefined, + name: document.getElementById('server-name').value, + host: document.getElementById('server-host').value, + username: document.getElementById('server-username').value, + useSudo: document.getElementById('server-use-sudo').checked + }; + + if (!server.name || !server.host) { + alert('Name and host are required'); + return; + } + + await window.api.saveServer(server); + await loadServers(); + renderServerList(); + clearServerForm(); +} + +// Log modal +function showLog(text) { + document.getElementById('log-content').textContent = text; + showModal(logModal); +} + +function appendLog(text) { + document.getElementById('log-content').textContent += text; +} + +// Modal helpers +function showModal(modal) { + modal.classList.remove('hidden'); +} + +function hideModal(modal) { + modal.classList.add('hidden'); +} + +// Required files checker +function getRequiredFiles(project) { + return [ + { + name: 'Dockerfile', + description: 'Container build instructions', + present: project.hasDockerfile, + fix: 'Create manually or use: npm run docker-deploy -- init' + }, + { + name: 'docker-compose.yml', + description: 'Runtime configuration (ports, env, volumes)', + present: project.hasDockerCompose, + fix: 'Create manually or use: npm run docker-deploy -- init' + }, + { + name: 'build-image-tar.ps1', + description: 'Script to build Docker image and create tar', + present: project.hasBuildScript, + fix: 'Use: npm run docker-deploy -- init' + }, + { + name: '.dockerignore', + description: 'Files to exclude from Docker image', + present: project.hasDockerIgnore, + fix: 'Create manually or use: npm run docker-deploy -- init' + } + ]; +} + +// Show project details modal +function showDetails(projectName) { + const project = state.localProjects.find(p => p.name === projectName); + if (!project) return; + + state.currentDetailsProject = project; + document.getElementById('details-project-name').textContent = projectName; + + // Render file checklist + const filesListEl = document.getElementById('details-files-list'); + const requiredFiles = getRequiredFiles(project); + + filesListEl.innerHTML = requiredFiles.map(file => ` +
+
+ ${file.present ? '✓' : '✗'} +
+
+
${file.name}
+
${file.description}
+
+
+ `).join(''); + + // Render fix instructions + const fixEl = document.getElementById('details-fix-instructions'); + const missingFiles = requiredFiles.filter(f => !f.present); + + if (missingFiles.length === 0) { + fixEl.innerHTML = ` +
+
All files present!
+

This project is ready for deployment.

+
+ `; + document.getElementById('btn-init-project').style.display = 'none'; + } else { + fixEl.innerHTML = ` +
+
Option 1: Use the CLI tool
+

Run this command to generate missing files:

+
cd "${project.path}"
npm run docker-deploy -- init .
+
This runs from the docker-deployment-manager repo
+
+
+
Option 2: Create manually
+

Missing files:

+ ${missingFiles.map(f => ` +
${f.name} - ${f.description}
+ `).join('')} +
+ `; + document.getElementById('btn-init-project').style.display = 'block'; + } + + showModal(detailsModal); +} + +// Initialize project via CLI +async function initCurrentProject() { + const project = state.currentDetailsProject; + if (!project) return; + + hideModal(detailsModal); + showLog(`Initializing Docker config for ${project.name}...\n\nRunning: npm run docker-deploy -- init "${project.path}" --no-interactive\n\n`); + + const result = await window.api.initProject(project.path); + + if (result.error) { + appendLog(`\nError: ${result.error}`); + } else { + appendLog(result.output); + appendLog('\n\nInit complete! Refreshing project list...'); + await loadLocalProjects(); + } +} + +// Container logs viewer +async function viewLogs(projectName) { + const project = state.localProjects.find(p => p.name === projectName); + if (!project || !state.selectedServerId) return; + + state.currentLogsProject = project; + document.getElementById('logs-container-name').textContent = projectName; + document.getElementById('logs-content').textContent = 'Loading logs...'; + + showModal(logsModal); + await refreshLogs(); +} + +async function refreshLogs() { + const project = state.currentLogsProject; + if (!project) return; + + const result = await window.api.getContainerLogs({ + serverId: state.selectedServerId, + remotePath: project.remotePath || `~/containers/${project.name}`, + lines: 100 + }); + + if (result.error) { + document.getElementById('logs-content').textContent = `Error: ${result.error}`; + } else { + document.getElementById('logs-content').textContent = result.logs || '(no logs)'; + } +} + +// Global function bindings for inline onclick +window.buildTar = buildTar; +window.deployProject = deployProject; +window.showDiff = showDiff; +window.showDetails = showDetails; +window.viewLogs = viewLogs; + +// Start app +init(); diff --git a/app/renderer-legacy/index.html b/app/renderer-legacy/index.html new file mode 100644 index 0000000..c2c6d7d --- /dev/null +++ b/app/renderer-legacy/index.html @@ -0,0 +1,193 @@ + + + + + + + Docker Deployment Manager + + + +
+
+

Docker Deployment Manager

+
+ + +
+
+ +
+ + + + +
+ +
+
+ + + + + + + + + + + + + + + + +
ProjectLocalDeployedRunningDiffActions
Loading projects...
+
+
+ + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/app/renderer-legacy/styles.css b/app/renderer-legacy/styles.css new file mode 100644 index 0000000..43bdf44 --- /dev/null +++ b/app/renderer-legacy/styles.css @@ -0,0 +1,652 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: #1a1a2e; + color: #eee; + min-height: 100vh; +} + +.app { + display: flex; + flex-direction: column; + height: 100vh; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + background: #16213e; + border-bottom: 1px solid #0f3460; +} + +.header h1 { + font-size: 20px; + font-weight: 600; + color: #e94560; +} + +.header-actions { + display: flex; + gap: 8px; +} + +.server-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 24px; + background: #16213e; + border-bottom: 1px solid #0f3460; +} + +.server-bar label { + font-weight: 500; +} + +.server-bar select { + padding: 8px 12px; + background: #0f3460; + border: 1px solid #1a1a2e; + color: #eee; + border-radius: 4px; + min-width: 200px; +} + +.status-badge { + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.status-badge.connected { + background: #0d7377; + color: #fff; +} + +.status-badge.error { + background: #e94560; + color: #fff; +} + +.main { + flex: 1; + overflow: auto; + padding: 24px; +} + +.btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: background 0.2s; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: #e94560; + color: #fff; +} + +.btn-primary:hover:not(:disabled) { + background: #ff6b6b; +} + +.btn-secondary { + background: #0f3460; + color: #eee; +} + +.btn-secondary:hover:not(:disabled) { + background: #16213e; +} + +.btn-small { + padding: 4px 8px; + font-size: 12px; +} + +.btn-icon { + padding: 4px 8px; + font-size: 14px; +} + +/* Table */ +.projects-table-container { + background: #16213e; + border-radius: 8px; + overflow: hidden; +} + +.projects-table { + width: 100%; + border-collapse: collapse; +} + +.projects-table th, +.projects-table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid #0f3460; +} + +.projects-table th { + background: #0f3460; + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.projects-table tr:hover { + background: rgba(233, 69, 96, 0.1); +} + +.projects-table .loading { + text-align: center; + color: #888; + padding: 40px; +} + +/* Status indicators */ +.indicator { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 6px; +} + +.indicator.green { background: #0d7377; } +.indicator.yellow { background: #f39c12; } +.indicator.red { background: #e94560; } +.indicator.gray { background: #555; } + +.status-cell { + display: flex; + align-items: center; + gap: 4px; +} + +.diff-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} + +.diff-badge.match { background: #0d7377; color: #fff; } +.diff-badge.different { background: #f39c12; color: #000; } +.diff-badge.missing { background: #e94560; color: #fff; } +.diff-badge.warning { background: #f39c12; color: #000; } +.diff-badge.unknown { background: #555; color: #fff; } + +.actions-cell { + display: flex; + gap: 4px; +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal.hidden { + display: none; +} + +.modal-content { + background: #16213e; + border-radius: 8px; + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow: auto; +} + +.modal-content.modal-wide { + max-width: 900px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #0f3460; +} + +.modal-header h2 { + font-size: 18px; +} + +.btn-close { + background: none; + border: none; + color: #888; + font-size: 24px; + cursor: pointer; +} + +.btn-close:hover { + color: #eee; +} + +.modal-body { + padding: 20px; +} + +/* Server form */ +.server-list { + margin-bottom: 20px; +} + +.server-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background: #0f3460; + border-radius: 4px; + margin-bottom: 8px; +} + +.server-item-info { + display: flex; + flex-direction: column; +} + +.server-item-name { + font-weight: 500; +} + +.server-item-host { + font-size: 12px; + color: #888; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-weight: 500; +} + +.form-group input { + width: 100%; + padding: 10px 12px; + background: #0f3460; + border: 1px solid #1a1a2e; + border-radius: 4px; + color: #eee; + font-size: 14px; +} + +.hint { + font-size: 12px; + color: #888; + margin-bottom: 16px; +} + +/* Diff view */ +.diff-section { + margin-bottom: 24px; +} + +.diff-section h3 { + margin-bottom: 12px; +} + +.diff-status { + margin-bottom: 12px; +} + +.diff-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 12px; +} + +.diff-panel { + background: #0f3460; + border-radius: 4px; + overflow: hidden; +} + +.diff-panel h4 { + padding: 8px 12px; + background: #1a1a2e; + font-size: 12px; + text-transform: uppercase; +} + +.diff-panel pre { + padding: 12px; + font-size: 12px; + line-height: 1.5; + overflow: auto; + max-height: 300px; + white-space: pre-wrap; + word-wrap: break-word; +} + +.diff-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +/* Log output */ +.log-output { + background: #0f3460; + padding: 16px; + border-radius: 4px; + font-size: 12px; + line-height: 1.6; + max-height: 400px; + overflow: auto; + white-space: pre-wrap; +} + +/* Project name styling */ +.project-name { + font-weight: 500; +} + +.project-path { + font-size: 11px; + color: #888; + display: block; +} + +/* Details modal - file checklist */ +.details-section { + margin-bottom: 24px; +} + +.details-section h3 { + margin-bottom: 12px; + color: #e94560; +} + +.file-checklist { + background: #0f3460; + border-radius: 4px; + padding: 12px; +} + +.file-item { + display: flex; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #1a1a2e; +} + +.file-item:last-child { + border-bottom: none; +} + +.file-item-icon { + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-right: 12px; + font-size: 14px; +} + +.file-item-icon.present { + background: #0d7377; + color: #fff; +} + +.file-item-icon.missing { + background: #e94560; + color: #fff; +} + +.file-item-info { + flex: 1; +} + +.file-item-name { + font-weight: 500; + font-family: monospace; +} + +.file-item-desc { + font-size: 11px; + color: #888; +} + +/* Fix instructions */ +.fix-instructions { + background: #0f3460; + border-radius: 4px; + padding: 16px; +} + +.fix-step { + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid #1a1a2e; +} + +.fix-step:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.fix-step-title { + font-weight: 500; + color: #f39c12; + margin-bottom: 8px; +} + +.fix-step-cmd { + background: #1a1a2e; + padding: 8px 12px; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + margin-top: 8px; + overflow-x: auto; +} + +.fix-step-note { + font-size: 12px; + color: #888; + margin-top: 8px; +} + +.details-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 16px; +} + +/* Issues badge */ +.issues-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + cursor: pointer; +} + +.issues-badge.ok { + background: #0d7377; + color: #fff; +} + +.issues-badge.warning { + background: #f39c12; + color: #000; +} + +.issues-badge.error { + background: #e94560; + color: #fff; +} + +.issues-badge:hover { + opacity: 0.8; +} + +/* Checkbox group */ +.checkbox-group { + display: flex; + align-items: center; +} + +.checkbox-group label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + margin-bottom: 0; +} + +.checkbox-group input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; +} + +/* Deploy confirmation modal */ +.deploy-diff-summary { + margin-bottom: 16px; + padding: 12px; + background: #0f3460; + border-radius: 4px; +} + +.deploy-diff-summary.has-diff { + border-left: 4px solid #f39c12; +} + +.deploy-diff-summary.no-diff { + border-left: 4px solid #0d7377; +} + +.deploy-diff-details { + margin-bottom: 16px; +} + +.deploy-file-diff { + margin-bottom: 16px; + background: #0f3460; + border-radius: 4px; + overflow: hidden; +} + +.deploy-file-diff h4 { + padding: 8px 12px; + background: #1a1a2e; + font-size: 13px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.deploy-file-diff .diff-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: #1a1a2e; +} + +.deploy-file-diff .diff-side { + background: #0f3460; + padding: 12px; +} + +.deploy-file-diff .diff-side-header { + font-size: 11px; + color: #888; + margin-bottom: 8px; + text-transform: uppercase; +} + +.deploy-file-diff pre { + font-size: 11px; + line-height: 1.5; + max-height: 200px; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; +} + +.deploy-confirm-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + padding-top: 16px; + border-top: 1px solid #0f3460; +} + +/* Logs modal */ +.logs-controls { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.logs-hint { + font-size: 12px; + color: #888; +} + +#logs-content { + max-height: 500px; + font-family: 'Consolas', 'Monaco', monospace; +} + +/* Server item with sudo badge */ +.server-item-badges { + display: flex; + gap: 4px; + margin-top: 4px; +} + +.server-sudo-badge { + font-size: 10px; + padding: 2px 6px; + background: #f39c12; + color: #000; + border-radius: 3px; +} diff --git a/app/renderer/components.json b/app/renderer/components.json new file mode 100644 index 0000000..7ff8f7d --- /dev/null +++ b/app/renderer/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/app/renderer/index.html b/app/renderer/index.html new file mode 100644 index 0000000..d6905e9 --- /dev/null +++ b/app/renderer/index.html @@ -0,0 +1,14 @@ + + + + + + + Docker Deployment Manager + + +
+ + + diff --git a/app/renderer/package.json b/app/renderer/package.json new file mode 100644 index 0000000..43ae2e0 --- /dev/null +++ b/app/renderer/package.json @@ -0,0 +1,39 @@ +{ + "name": "docker-deployment-manager-renderer", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0", + "@tanstack/react-query": "^5.0.0", + "@radix-ui/react-dialog": "^1.1.0", + "@radix-ui/react-select": "^2.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.0", + "@radix-ui/react-popover": "^1.1.0", + "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.0", + "@radix-ui/react-dropdown-menu": "^2.1.0", + "@radix-ui/react-slot": "^1.1.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "tailwind-merge": "^2.5.0", + "lucide-react": "^0.460.0", + "sonner": "^1.7.0" + }, + "devDependencies": { + "vite": "^6.0.0", + "@vitejs/plugin-react": "^4.3.0", + "tailwindcss": "^3.4.0", + "postcss": "^8.4.0", + "autoprefixer": "^10.4.0", + "tailwindcss-animate": "^1.0.0" + } +} diff --git a/app/renderer/postcss.config.js b/app/renderer/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/app/renderer/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/app/renderer/src/app.jsx b/app/renderer/src/app.jsx new file mode 100644 index 0000000..933f6c6 --- /dev/null +++ b/app/renderer/src/app.jsx @@ -0,0 +1,51 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createHashRouter, RouterProvider } from 'react-router-dom'; +import { Toaster } from 'sonner'; +import { AppProvider } from '@/lib/app-context'; +import { AppLayout } from '@/components/layout/app-layout'; +import { DashboardPage } from '@/components/dashboard/dashboard-page'; +import { ServersPage } from '@/components/servers/servers-page'; +import { ProjectsPage } from '@/components/projects/projects-page'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +const router = createHashRouter([ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'servers', element: }, + { path: 'projects', element: }, + { path: 'projects/:projectName', element: }, + ], + }, +]); + +export default function App() { + return ( + + + + + + + ); +} diff --git a/app/renderer/src/components/dashboard/dashboard-page.jsx b/app/renderer/src/components/dashboard/dashboard-page.jsx new file mode 100644 index 0000000..c7b8223 --- /dev/null +++ b/app/renderer/src/components/dashboard/dashboard-page.jsx @@ -0,0 +1,151 @@ +import { Server, Container, Activity, AlertCircle } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { StatusDot } from '@/components/shared/status-dot'; +import { EmptyState } from '@/components/shared/empty-state'; +import { useServers } from '@/hooks/use-servers'; +import { useLocalProjects } from '@/hooks/use-projects'; +import { useRunningContainers } from '@/hooks/use-projects'; +import { useAppContext } from '@/lib/app-context'; +import { useNavigate } from 'react-router-dom'; + +export function DashboardPage() { + const { data: servers, isLoading: serversLoading } = useServers(); + const { data: localProjects } = useLocalProjects(); + const { selectedServerId } = useAppContext(); + const { data: containersResult } = useRunningContainers(selectedServerId); + const navigate = useNavigate(); + + const containers = containersResult?.containers || []; + const running = containers.filter((c) => c.status?.includes('Up')).length; + const stopped = containers.length - running; + + return ( +
+
+

Dashboard

+

Fleet health overview

+
+ + {/* Fleet summary */} +
+ + + + 0 ? 'red' : 'gray'} + /> +
+ + {/* Server cards */} +
+

Servers

+ {!servers || servers.length === 0 ? ( + + + + ) : ( +
+ {servers.map((server) => ( + + ))} +
+ )} +
+
+ ); +} + +function SummaryCard({ icon: Icon, label, value, color }) { + return ( + + +
+ +
+
+

{value}

+

{label}

+
+
+
+ ); +} + +function ServerHealthCard({ server }) { + const { selectedServerId, setSelectedServerId } = useAppContext(); + const isActive = selectedServerId === server.id; + const { data: containersResult } = useRunningContainers(isActive ? server.id : null); + const navigate = useNavigate(); + + const containers = containersResult?.containers || []; + const running = containers.filter((c) => c.status?.includes('Up')).length; + + return ( + { + setSelectedServerId(server.id); + }} + > + +
+ + 0 ? 'green' : 'gray'} /> + {server.name} + + {server.useSudo && ( + sudo + )} +
+
+ +

{server.host}

+ {isActive ? ( +
+ {running} running + {containers.length} total +
+ ) : ( +

Click to select

+ )} + {isActive && ( + + )} +
+
+ ); +} diff --git a/app/renderer/src/components/deploy/deploy-button.jsx b/app/renderer/src/components/deploy/deploy-button.jsx new file mode 100644 index 0000000..a15859a --- /dev/null +++ b/app/renderer/src/components/deploy/deploy-button.jsx @@ -0,0 +1,262 @@ +import { useState } from 'react'; +import { Rocket, Hammer, Loader2, Wrench } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import { DiffStatusBadge } from '@/components/projects/project-status-badge'; +import { useCompareProject, useInitProject } from '@/hooks/use-compare'; +import { useBuildTar, useDeployProject } from '@/hooks/use-deploy'; +import { useAppContext } from '@/lib/app-context'; +import { maskEnvContent } from '@/lib/utils'; +import { toast } from 'sonner'; + +export function DeployActions({ project }) { + const { selectedServerId } = useAppContext(); + const buildTar = useBuildTar(); + const deployProject = useDeployProject(); + const compare = useCompareProject(); + const initProject = useInitProject(); + + const [buildLog, setBuildLog] = useState(null); + const [deployLog, setDeployLog] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [diff, setDiff] = useState(null); + + const remotePath = project.remotePath || `~/containers/${project.name}`; + + function handleBuild() { + setBuildLog('Building Docker image...'); + buildTar.mutate(project.path, { + onSuccess: (result) => { + if (result.error) { + setBuildLog(`Error: ${result.error}\n${result.stderr || ''}`); + toast.error('Build failed'); + } else { + setBuildLog(result.output + '\n\nBuild complete!'); + toast.success('Build complete'); + } + }, + onError: (err) => { + setBuildLog(`Error: ${err.message}`); + toast.error('Build failed'); + }, + }); + } + + function handleDeploy() { + if (!selectedServerId) { + toast.error('Select a server first'); + return; + } + + compare.mutate( + { projectPath: project.path, serverId: selectedServerId, remotePath }, + { + onSuccess: (result) => { + if (result.error) { + toast.error(`Compare failed: ${result.error}`); + return; + } + + const d = result.diff; + const filesWithDiff = (d.files || []).filter( + (f) => f.status === 'different' || f.status === 'local-missing' + ); + + if (filesWithDiff.length > 0) { + setDiff(d); + setConfirmOpen(true); + } else { + executeDeploy(); + } + }, + onError: (err) => toast.error(err.message), + } + ); + } + + function executeDeploy() { + setConfirmOpen(false); + setDeployLog('Deploying...'); + + deployProject.mutate( + { projectPath: project.path, serverId: selectedServerId, remotePath }, + { + onSuccess: (result) => { + if (result.error) { + setDeployLog(`Error: ${result.error}`); + toast.error('Deploy failed'); + } else { + let log = ''; + if (result.uploadedFiles?.length) { + log += `Uploaded:\n - ${result.uploadedFiles.join('\n - ')}\n\n`; + } + log += result.message || 'Deployment complete!'; + if (result.status) log += `\n\nStatus:\n${result.status}`; + log += `\n\nHealth: ${result.healthy ? 'PASSED' : 'PENDING'}`; + setDeployLog(log); + toast.success(result.healthy ? 'Deploy successful' : 'Deployed (health check pending)'); + } + }, + onError: (err) => { + setDeployLog(`Error: ${err.message}`); + toast.error('Deploy failed'); + }, + } + ); + } + + function handleInit() { + initProject.mutate(project.path, { + onSuccess: (result) => { + if (result.error) { + toast.error(`Init failed: ${result.error}`); + } else { + toast.success('Project initialized'); + } + }, + }); + } + + return ( +
+ {/* Action buttons */} +
+ {project.path && project.hasDockerfile && ( + + )} + + {project.path && selectedServerId && ( + + )} + + {project.path && project.dockerStatus !== 'configured' && ( + + )} +
+ + {/* Build log */} + {buildLog && ( +
+
+ Build Output + +
+
+            {buildLog}
+          
+
+ )} + + {/* Deploy log */} + {deployLog && ( +
+
+ Deploy Output + +
+
+            {deployLog}
+          
+
+ )} + + {/* Deploy confirmation dialog */} + + + + Confirm Deployment: {project.name} + + Differences found between local and remote files. Continuing will overwrite remote. + + + +
+ {(diff?.files || []).map((file) => + file.status === 'different' || file.status === 'local-missing' ? ( +
+
+ {file.name} + +
+ {(file.localContent || file.remoteContent) && ( +
+
+
Local
+
+                          {(file.sensitive ? maskEnvContent(file.localContent) : file.localContent) || '(not found)'}
+                        
+
+
+
Remote
+
+                          {(file.sensitive ? maskEnvContent(file.remoteContent) : file.remoteContent) || '(not found)'}
+                        
+
+
+ )} +
+ ) : null + )} +
+ + + + + +
+
+
+ ); +} diff --git a/app/renderer/src/components/diff/diff-viewer.jsx b/app/renderer/src/components/diff/diff-viewer.jsx new file mode 100644 index 0000000..7fa3e52 --- /dev/null +++ b/app/renderer/src/components/diff/diff-viewer.jsx @@ -0,0 +1,207 @@ +import { useState } from 'react'; +import { FileCode, Download, Upload, ExternalLink, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { DiffStatusBadge } from '@/components/projects/project-status-badge'; +import { useCompareProject } from '@/hooks/use-compare'; +import { usePullFile, usePullFiles } from '@/hooks/use-sync'; +import { useAppContext } from '@/lib/app-context'; +import { maskEnvContent } from '@/lib/utils'; +import { toolsApi } from '@/lib/api'; +import { toast } from 'sonner'; + +export function DiffViewer({ project }) { + const { selectedServerId } = useAppContext(); + const compare = useCompareProject(); + const pullFile = usePullFile(); + const pullFiles = usePullFiles(); + const [diff, setDiff] = useState(null); + + const remotePath = project.remotePath || `~/containers/${project.name}`; + + function handleCompare() { + compare.mutate( + { projectPath: project.path, serverId: selectedServerId, remotePath }, + { + onSuccess: (result) => { + if (result.error) { + toast.error(`Compare failed: ${result.error}`); + } else { + setDiff(result.diff); + } + }, + onError: (err) => toast.error(err.message), + } + ); + } + + function handlePull(file) { + pullFile.mutate( + { + serverId: selectedServerId, + remotePath: file.remotePath, + localPath: file.localPath, + isDirectory: file.type === 'directory', + }, + { + onSuccess: (result) => { + if (result.error) { + toast.error(`Pull failed: ${result.error}`); + } else { + toast.success(`Pulled ${file.name}`); + handleCompare(); // refresh + } + }, + } + ); + } + + function handlePullAll() { + const filesToPull = (diff?.files || []).filter( + (f) => (f.status === 'different' || f.status === 'local-missing') && (f.remoteContent || f.type === 'directory') + ); + if (!filesToPull.length) return; + + pullFiles.mutate( + { serverId: selectedServerId, files: filesToPull }, + { + onSuccess: (result) => { + if (result.error) { + toast.error(result.error); + } else { + toast.success(`Pulled ${result.pulled?.length || 0} files`); + handleCompare(); + } + }, + } + ); + } + + function handleVSCodeDiff(file) { + toolsApi.openVSCodeDiff({ + localPath: file.localPath, + serverId: selectedServerId, + remoteFilePath: file.remotePath, + }); + } + + if (!selectedServerId) { + return ( +
+ Select a server to compare files +
+ ); + } + + if (!project.path) { + return ( +
+ No local project path +
+ ); + } + + return ( +
+
+ + {diff && (() => { + const pullable = (diff.files || []).filter( + (f) => f.status === 'different' || f.status === 'local-missing' + ); + return pullable.length > 0 ? ( + + ) : null; + })()} +
+ + {diff && ( +
+ {(diff.files || []).map((file, i) => ( + handlePull(file)} + onVSCodeDiff={() => handleVSCodeDiff(file)} + isPulling={pullFile.isPending} + /> + ))} + {(!diff.files || diff.files.length === 0) && ( +

No files to compare

+ )} +
+ )} +
+ ); +} + +function DiffFileCard({ file, onPull, onVSCodeDiff, isPulling }) { + const hasContent = file.localContent || file.remoteContent; + const localDisplay = file.sensitive ? maskEnvContent(file.localContent) : file.localContent; + const remoteDisplay = file.sensitive ? maskEnvContent(file.remoteContent) : file.remoteContent; + + return ( +
+
+
+ {file.name} + +
+
+ {(file.status === 'different' || file.status === 'local-missing') && ( + + )} + {file.status === 'different' && file.type !== 'directory' && ( + + )} +
+
+ + {hasContent && file.status !== 'match' && ( +
+
+
+ Local +
+
+              {localDisplay || '(not found)'}
+            
+
+
+
+ Remote +
+
+              {remoteDisplay || '(not found)'}
+            
+
+
+ )} +
+ ); +} diff --git a/app/renderer/src/components/layout/app-layout.jsx b/app/renderer/src/components/layout/app-layout.jsx new file mode 100644 index 0000000..532d468 --- /dev/null +++ b/app/renderer/src/components/layout/app-layout.jsx @@ -0,0 +1,19 @@ +import { Outlet } from 'react-router-dom'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { ErrorBoundary } from '@/components/shared/error-boundary'; +import { Sidebar } from './sidebar'; + +export function AppLayout() { + return ( + +
+ +
+ + + +
+
+
+ ); +} diff --git a/app/renderer/src/components/layout/sidebar.jsx b/app/renderer/src/components/layout/sidebar.jsx new file mode 100644 index 0000000..b5f39e9 --- /dev/null +++ b/app/renderer/src/components/layout/sidebar.jsx @@ -0,0 +1,75 @@ +import { NavLink } from 'react-router-dom'; +import { LayoutDashboard, Container, Settings } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Separator } from '@/components/ui/separator'; +import { useServers } from '@/hooks/use-servers'; +import { useAppContext } from '@/lib/app-context'; + +const navItems = [ + { to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true }, + { to: '/projects', icon: Container, label: 'Projects' }, + { to: '/servers', icon: Settings, label: 'Settings' }, +]; + +export function Sidebar() { + const { data: servers } = useServers(); + const { selectedServerId, setSelectedServerId } = useAppContext(); + + return ( + + ); +} diff --git a/app/renderer/src/components/projects/project-detail.jsx b/app/renderer/src/components/projects/project-detail.jsx new file mode 100644 index 0000000..d5ece00 --- /dev/null +++ b/app/renderer/src/components/projects/project-detail.jsx @@ -0,0 +1,134 @@ +import { FileCode, Check, X } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { StatusDot } from '@/components/shared/status-dot'; +import { ProjectStatusBadge, RunningStatusBadge, getRequiredFiles } from './project-status-badge'; +import { DiffViewer } from '@/components/diff/diff-viewer'; +import { LogsPanel } from '@/components/logs/logs-panel'; +import { DeployActions } from '@/components/deploy/deploy-button'; +import { useAppContext } from '@/lib/app-context'; + +export function ProjectDetail({ project }) { + const { selectedServerId } = useAppContext(); + + return ( +
+ {/* Header */} +
+
+

+ {project.name} + +

+ {project.path && ( +

{project.path}

+ )} + {project.running?.ports && ( +

Ports: {project.running.ports}

+ )} +
+ +
+ + {/* Tabs */} + + + Overview + Files + Logs + + + + {/* File checklist */} + {project.path && ( + + +

+ + Required Files +

+
+ {getRequiredFiles(project).map((file) => ( +
+ {file.present ? ( + + ) : ( + + )} + {file.name} +
+ ))} +
+
+
+ )} + + {/* Status cards */} +
+ + + +
+ + {/* Deploy actions */} + +
+ + + + + + + + +
+
+ ); +} + +function StatusCard({ label, value, color }) { + return ( + + +

{label}

+
+ + {value} +
+
+
+ ); +} diff --git a/app/renderer/src/components/projects/project-list-item.jsx b/app/renderer/src/components/projects/project-list-item.jsx new file mode 100644 index 0000000..13b7256 --- /dev/null +++ b/app/renderer/src/components/projects/project-list-item.jsx @@ -0,0 +1,37 @@ +import { cn } from '@/lib/utils'; +import { StatusDot } from '@/components/shared/status-dot'; +import { Badge } from '@/components/ui/badge'; + +export function ProjectListItem({ project, isSelected, onClick }) { + const statusColor = project.running?.status?.includes('Up') + ? 'green' + : project.deployed + ? 'red' + : 'gray'; + + return ( + + ); +} diff --git a/app/renderer/src/components/projects/project-search.jsx b/app/renderer/src/components/projects/project-search.jsx new file mode 100644 index 0000000..5884a6d --- /dev/null +++ b/app/renderer/src/components/projects/project-search.jsx @@ -0,0 +1,33 @@ +import { Search } from 'lucide-react'; +import { Input } from '@/components/ui/input'; + +export function ProjectSearch({ value, onChange, statusFilter, onStatusFilterChange }) { + return ( +
+
+ + onChange(e.target.value)} + className="pl-9 h-9" + /> +
+
+ {['all', 'running', 'stopped', 'not-deployed'].map((filter) => ( + + ))} +
+
+ ); +} diff --git a/app/renderer/src/components/projects/project-status-badge.jsx b/app/renderer/src/components/projects/project-status-badge.jsx new file mode 100644 index 0000000..43bd36f --- /dev/null +++ b/app/renderer/src/components/projects/project-status-badge.jsx @@ -0,0 +1,60 @@ +import { Badge } from '@/components/ui/badge'; + +export function ProjectStatusBadge({ project }) { + if (!project.path) { + return Remote only; + } + + const missing = getRequiredFiles(project).filter((f) => !f.present).length; + + if (missing === 0) { + return Ready; + } + if (missing <= 2) { + return {missing} missing; + } + return {missing} missing; +} + +export function RunningStatusBadge({ running, deployed }) { + if (running) { + const isUp = running.status?.includes('Up'); + return ( + + {isUp ? 'Running' : 'Stopped'} + + ); + } + if (deployed) { + return Stopped; + } + return null; +} + +export function DiffStatusBadge({ status }) { + switch (status) { + case 'match': + return Match; + case 'different': + return Different; + case 'remote-missing': + return Not on server; + case 'local-missing': + return Only on server; + case 'both-exist': + return Both exist; + case 'neither': + return Neither; + default: + return Unknown; + } +} + +export function getRequiredFiles(project) { + return [ + { name: 'Dockerfile', present: project.hasDockerfile }, + { name: 'docker-compose.yml', present: project.hasDockerCompose }, + { name: 'build-image-tar.ps1', present: project.hasBuildScript }, + { name: '.dockerignore', present: project.hasDockerIgnore }, + ]; +} diff --git a/app/renderer/src/components/projects/projects-page.jsx b/app/renderer/src/components/projects/projects-page.jsx new file mode 100644 index 0000000..6430aba --- /dev/null +++ b/app/renderer/src/components/projects/projects-page.jsx @@ -0,0 +1,101 @@ +import { useState, useMemo } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { Container } from 'lucide-react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { EmptyState } from '@/components/shared/empty-state'; +import { ProjectSearch } from './project-search'; +import { ProjectListItem } from './project-list-item'; +import { ProjectDetail } from './project-detail'; +import { useMergedProjects } from '@/hooks/use-merged-projects'; +import { useAppContext } from '@/lib/app-context'; + +export function ProjectsPage() { + const { projectName } = useParams(); + const navigate = useNavigate(); + const { selectedServerId } = useAppContext(); + const { projects, isLoading } = useMergedProjects(selectedServerId); + + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + + const filtered = useMemo(() => { + let result = projects; + + // Text search + if (search) { + const q = search.toLowerCase(); + result = result.filter((p) => p.name.toLowerCase().includes(q)); + } + + // Status filter + if (statusFilter === 'running') { + result = result.filter((p) => p.running?.status?.includes('Up')); + } else if (statusFilter === 'stopped') { + result = result.filter((p) => p.deployed && !p.running?.status?.includes('Up')); + } else if (statusFilter === 'not-deployed') { + result = result.filter((p) => !p.deployed); + } + + return result; + }, [projects, search, statusFilter]); + + const selectedProject = projectName + ? projects.find((p) => p.name === decodeURIComponent(projectName)) + : null; + + return ( +
+ {/* Left panel — project list */} +
+
+ +
+ +
+ + {filtered.length} project{filtered.length !== 1 ? 's' : ''} + {isLoading ? ' (loading...)' : ''} + +
+ + +
+ {filtered.map((project) => ( + navigate(`/projects/${encodeURIComponent(project.name)}`)} + /> + ))} + {filtered.length === 0 && !isLoading && ( +
+ No projects found +
+ )} +
+
+
+ + {/* Right panel — project detail */} +
+ {selectedProject ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/app/renderer/src/components/servers/servers-page.jsx b/app/renderer/src/components/servers/servers-page.jsx new file mode 100644 index 0000000..cb2efa3 --- /dev/null +++ b/app/renderer/src/components/servers/servers-page.jsx @@ -0,0 +1,271 @@ +import { useState } from 'react'; +import { Server, Plus, Pencil, Trash2, FolderOpen, Save } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Separator } from '@/components/ui/separator'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { EmptyState } from '@/components/shared/empty-state'; +import { useServers, useSaveServer, useDeleteServer } from '@/hooks/use-servers'; +import { useConfig, useSaveConfig } from '@/hooks/use-config'; +import { toast } from 'sonner'; + +export function ServersPage() { + const { data: servers, isLoading } = useServers(); + const { data: config } = useConfig(); + const saveConfig = useSaveConfig(); + const saveServer = useSaveServer(); + const deleteServer = useDeleteServer(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingServer, setEditingServer] = useState(null); + const [projectsRoot, setProjectsRoot] = useState(''); + + // Sync local state when config loads + const currentRoot = projectsRoot || config?.projectsRoot || ''; + + function handleAdd() { + setEditingServer(null); + setDialogOpen(true); + } + + function handleEdit(server) { + setEditingServer(server); + setDialogOpen(true); + } + + function handleDelete(serverId) { + if (confirm('Delete this server?')) { + deleteServer.mutate(serverId); + } + } + + function handleSave(formData) { + saveServer.mutate(formData, { + onSuccess: () => setDialogOpen(false), + }); + } + + function handleSaveRoot() { + if (!currentRoot) return; + saveConfig.mutate( + { projectsRoot: currentRoot }, + { + onSuccess: () => { + toast.success('Projects root updated — go to Projects to rescan'); + setProjectsRoot(''); + }, + onError: () => toast.error('Failed to save'), + } + ); + } + + return ( +
+
+
+

Settings

+

Servers and project configuration

+
+ +
+ + {/* Projects Root Path */} + + + + + Projects Root + + + +

+ The folder containing your project repositories. The app scans subdirectories (up to 2 levels deep) for Dockerfiles. +

+
+ setProjectsRoot(e.target.value)} + placeholder="e.g., C:\.bucket\repos.gitea" + className="font-mono text-xs" + /> + +
+
+
+ + + + {/* Servers */} +
+

Servers

+ {!servers || servers.length === 0 ? ( + + + + ) : ( +
+ {servers.map((server) => ( + + +
+ {server.name} +
+ + +
+
+
+ +

{server.host}

+ {server.username && ( +

User: {server.username}

+ )} +
+ {server.useSudo && sudo} +
+
+
+ ))} +
+ )} +
+ + +
+ ); +} + +function ServerDialog({ open, onOpenChange, server, onSave, isPending }) { + const [name, setName] = useState(''); + const [host, setHost] = useState(''); + const [username, setUsername] = useState(''); + const [useSudo, setUseSudo] = useState(false); + + // Reset form when dialog opens + function handleOpenChange(open) { + if (open) { + setName(server?.name || ''); + setHost(server?.host || ''); + setUsername(server?.username || ''); + setUseSudo(server?.useSudo || false); + } + onOpenChange(open); + } + + function handleSubmit(e) { + e.preventDefault(); + if (!name || !host) return; + onSave({ + id: server?.id, + name, + host, + username, + useSudo, + }); + } + + return ( + + + + {server ? 'Edit Server' : 'Add Server'} + + SSH credentials are read from the .env file (SSH_USERNAME, SSH_PASSWORD) + + +
+
+ + setName(e.target.value)} + /> +
+
+ + setHost(e.target.value)} + /> +
+
+ + setUsername(e.target.value)} + /> +
+
+ + +
+ + + +
+
+
+ ); +} diff --git a/app/renderer/src/components/shared/empty-state.jsx b/app/renderer/src/components/shared/empty-state.jsx new file mode 100644 index 0000000..9107b46 --- /dev/null +++ b/app/renderer/src/components/shared/empty-state.jsx @@ -0,0 +1,12 @@ +import { cn } from '@/lib/utils'; + +export function EmptyState({ icon: Icon, title, description, children, className }) { + return ( +
+ {Icon && } + {title &&

{title}

} + {description &&

{description}

} + {children &&
{children}
} +
+ ); +} diff --git a/app/renderer/src/components/shared/error-boundary.jsx b/app/renderer/src/components/shared/error-boundary.jsx new file mode 100644 index 0000000..a16accf --- /dev/null +++ b/app/renderer/src/components/shared/error-boundary.jsx @@ -0,0 +1,36 @@ +import { Component } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( +
+ +

Something went wrong

+

+ {this.state.error?.message || 'An unexpected error occurred'} +

+ +
+ ); + } + + return this.props.children; + } +} diff --git a/app/renderer/src/components/shared/status-dot.jsx b/app/renderer/src/components/shared/status-dot.jsx new file mode 100644 index 0000000..a64e4a2 --- /dev/null +++ b/app/renderer/src/components/shared/status-dot.jsx @@ -0,0 +1,16 @@ +import { cn } from '@/lib/utils'; + +const colorMap = { + green: 'bg-[hsl(180,80%,25%)]', + red: 'bg-[hsl(349,78%,58%)]', + yellow: 'bg-[hsl(37,90%,58%)]', + gray: 'bg-[hsl(0,0%,33%)]', +}; + +export function StatusDot({ color = 'gray', className }) { + return ( + + ); +} diff --git a/app/renderer/src/components/ui/badge.jsx b/app/renderer/src/components/ui/badge.jsx new file mode 100644 index 0000000..5f74f0e --- /dev/null +++ b/app/renderer/src/components/ui/badge.jsx @@ -0,0 +1,27 @@ +import { cva } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground shadow', + secondary: 'border-transparent bg-secondary text-secondary-foreground', + destructive: 'border-transparent bg-destructive text-destructive-foreground shadow', + outline: 'text-foreground', + success: 'border-transparent bg-[hsl(var(--success))] text-[hsl(var(--success-foreground))]', + warning: 'border-transparent bg-[hsl(var(--warning))] text-[hsl(var(--warning-foreground))]', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +function Badge({ className, variant, ...props }) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/app/renderer/src/components/ui/button.jsx b/app/renderer/src/components/ui/button.jsx new file mode 100644 index 0000000..ae8f335 --- /dev/null +++ b/app/renderer/src/components/ui/button.jsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); + } +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/app/renderer/src/components/ui/card.jsx b/app/renderer/src/components/ui/card.jsx new file mode 100644 index 0000000..57040cf --- /dev/null +++ b/app/renderer/src/components/ui/card.jsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/app/renderer/src/components/ui/checkbox.jsx b/app/renderer/src/components/ui/checkbox.jsx new file mode 100644 index 0000000..e7a212f --- /dev/null +++ b/app/renderer/src/components/ui/checkbox.jsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const Checkbox = React.forwardRef(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/app/renderer/src/components/ui/dialog.jsx b/app/renderer/src/components/ui/dialog.jsx new file mode 100644 index 0000000..d4f487c --- /dev/null +++ b/app/renderer/src/components/ui/dialog.jsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ className, ...props }) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/app/renderer/src/components/ui/input.jsx b/app/renderer/src/components/ui/input.jsx new file mode 100644 index 0000000..c5b94cb --- /dev/null +++ b/app/renderer/src/components/ui/input.jsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Input = React.forwardRef(({ className, type, ...props }, ref) => { + return ( + + ); +}); +Input.displayName = 'Input'; + +export { Input }; diff --git a/app/renderer/src/components/ui/label.jsx b/app/renderer/src/components/ui/label.jsx new file mode 100644 index 0000000..c26899a --- /dev/null +++ b/app/renderer/src/components/ui/label.jsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Label = React.forwardRef(({ className, ...props }, ref) => ( +