coolify integration.

This commit is contained in:
2026-02-27 08:55:41 -06:00
parent fe66be4aad
commit 2fe49b6725
62 changed files with 6366 additions and 129 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(ssh:*)"
]
}
}

4
.gitignore vendored
View File

@@ -7,6 +7,7 @@ package-lock.json
.env.local .env.local
.env.*.local .env.*.local
config.json config.json
deployment-config.json
# Docker artifacts # Docker artifacts
*.tar *.tar
@@ -34,3 +35,6 @@ coverage/
# Temporary files # Temporary files
tmp/ tmp/
temp/ temp/
# Vite build output
app/renderer/dist/

820
README.md
View File

@@ -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 Flow: edit → git push → Gitea webhook → Coolify auto-builds & deploys
# 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
``` ```
## Commands ## Server Setup
### detect - **192.168.69.4** (box-stableapps): Coolify host + Gitea host + stable apps
Identify project type without generating files. - **192.168.69.5** (box-repoapps): Working/repo apps, managed as remote server by Coolify
```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 <type> Force specific project type
# --port <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
--- ---
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 <username>! 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/<key-file> 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/<key-file> -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.

5
app/.env.example Normal file
View File

@@ -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

150
app/README.md Normal file
View File

@@ -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
```

626
app/main/index.js Normal file
View File

@@ -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 };
}
});

123
app/main/project-scanner.js Normal file
View File

@@ -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 };

280
app/main/server-scanner.js Normal file
View File

@@ -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 };

196
app/main/ssh-service.js Normal file
View File

@@ -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 };

22
app/package.json Normal file
View File

@@ -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"
}
}

33
app/preload.js Normal file
View File

@@ -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)
});

922
app/renderer-legacy/app.js Normal file
View File

@@ -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 = '<option value="">-- Select Server --</option>';
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 = '<tr><td colspan="6" class="loading">Scanning local projects...</td></tr>';
try {
state.localProjects = await window.api.scanLocalProjects();
renderProjects();
} catch (err) {
projectsBody.innerHTML = `<tr><td colspan="6" class="loading">Error: ${err.message}</td></tr>`;
}
}
// 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 = '<tr><td colspan="6" class="loading">No projects found with Dockerfiles</td></tr>';
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 = `
<td>
<span class="project-name">${name}</span>
${project.path ? `<span class="project-path">${project.path}</span>` : ''}
</td>
<td class="status-cell">
${renderLocalStatus(project)}
</td>
<td class="status-cell">
${renderDeployedStatus(project)}
</td>
<td class="status-cell">
${renderRunningStatus(project)}
</td>
<td>
${renderDiffStatus(project)}
</td>
<td class="actions-cell">
${renderActions(project)}
</td>
`;
projectsBody.appendChild(row);
}
}
function renderLocalStatus(project) {
if (!project.path) {
return '<span class="indicator gray"></span>Not local';
}
// Count missing files
const requiredFiles = getRequiredFiles(project);
const missingCount = requiredFiles.filter(f => !f.present).length;
if (missingCount === 0) {
return `<span class="issues-badge ok" onclick="showDetails('${project.name}')">Ready</span>`;
} else if (missingCount <= 2) {
return `<span class="issues-badge warning" onclick="showDetails('${project.name}')">${missingCount} missing</span>`;
} else {
return `<span class="issues-badge error" onclick="showDetails('${project.name}')">${missingCount} missing</span>`;
}
}
function renderDeployedStatus(project) {
if (!state.selectedServerId) {
return '<span class="indicator gray"></span>Select server';
}
if (project.deployed) {
return '<span class="indicator green"></span>Deployed';
} else {
return '<span class="indicator gray"></span>Not deployed';
}
}
function renderRunningStatus(project) {
if (!state.selectedServerId) {
return '-';
}
if (project.running) {
return `<span class="indicator green"></span>${project.running.status.split(' ')[0]}`;
} else if (project.deployed) {
return '<span class="indicator red"></span>Stopped';
} else {
return '-';
}
}
function renderDiffStatus(project) {
if (!project.deployed || !project.path) {
return '<span class="diff-badge unknown">N/A</span>';
}
// If we have deployed content, we can compare
if (project.deployed.dockerComposeContent && project.hasDockerCompose) {
return `<button class="btn btn-small btn-secondary" onclick="showDiff('${project.name}')">Compare</button>`;
}
return '<span class="diff-badge unknown">?</span>';
}
function renderActions(project) {
const actions = [];
if (project.path && project.hasDockerfile) {
actions.push(`<button class="btn btn-small btn-secondary" onclick="buildTar('${project.path}')">Build</button>`);
}
if (project.path && state.selectedServerId) {
actions.push(`<button class="btn btn-small btn-primary" onclick="deployProject('${project.name}')">Deploy</button>`);
}
// Add logs button for running containers
if (project.running && state.selectedServerId) {
actions.push(`<button class="btn btn-small btn-secondary" onclick="viewLogs('${project.name}')">Logs</button>`);
}
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 = `
<strong>Differences found!</strong> ${filesWithDiff.length} file(s) differ from deployed versions.
<br>Continuing will overwrite the remote files.
${filesWithDiff.some(f => f.status === 'local-missing') ?
'<br><span style="color: #f39c12;">Warning: Some files exist on server but not locally.</span>' : ''}
`;
} else {
summaryEl.className = 'deploy-diff-summary no-diff';
summaryEl.innerHTML = `<strong>No differences found.</strong> 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 += `
<div class="deploy-file-diff">
<h4>
${file.name} ${statusBadge}
<button class="btn btn-small btn-secondary" onclick="pullSingleFile(${fileIndex})" title="Pull from server">Pull</button>
</h4>
<div class="diff-content">
<div class="diff-side">
<div class="diff-side-header">Local</div>
<pre>${escapeHtml(content.local || '(empty)')}</pre>
</div>
<div class="diff-side">
<div class="diff-side-header">Remote</div>
<pre>${escapeHtml(content.remote || '(empty)')}</pre>
</div>
</div>
</div>
`;
} else if (file.status === 'local-missing' && file.remoteContent) {
// File exists on server but not locally
detailsEl.innerHTML += `
<div class="deploy-file-diff">
<h4>
${file.name} ${statusBadge}
<button class="btn btn-small btn-secondary" onclick="pullSingleFile(${fileIndex})" title="Pull from server">Pull</button>
</h4>
<div class="diff-content">
<div class="diff-side">
<div class="diff-side-header">Local</div>
<pre>(not found)</pre>
</div>
<div class="diff-side">
<div class="diff-side-header">Remote</div>
<pre>${escapeHtml(file.sensitive ? maskEnvContent(file.remoteContent) : file.remoteContent)}</pre>
</div>
</div>
</div>
`;
} else if (file.type === 'directory' && file.status !== 'neither') {
// Directory status
detailsEl.innerHTML += `
<div class="deploy-file-diff" style="padding: 12px;">
<h4 style="margin: 0;">
${file.name} ${statusBadge}
${file.status === 'local-missing' || file.status === 'both-exist' ?
`<button class="btn btn-small btn-secondary" onclick="pullSingleFile(${fileIndex})" title="Pull from server">Pull</button>` : ''}
</h4>
</div>
`;
}
}
// 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 += `
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #0f3460;">
<button class="btn btn-secondary" onclick="pullAllDifferent()">
Pull All Different Files (${pullableFiles.length})
</button>
</div>
`;
}
showModal(deployConfirmModal);
}
function getStatusBadge(status) {
switch (status) {
case 'match':
return '<span class="diff-badge match">Match</span>';
case 'different':
return '<span class="diff-badge different">Different</span>';
case 'remote-missing':
return '<span class="diff-badge missing">Not on server</span>';
case 'local-missing':
return '<span class="diff-badge warning" style="background: #f39c12; color: #000;">Only on server</span>';
case 'both-exist':
return '<span class="diff-badge match">Both exist</span>';
case 'neither':
return '<span class="diff-badge unknown">Neither</span>';
default:
return '<span class="diff-badge unknown">Unknown</span>';
}
}
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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 = '<span class="diff-badge match">Files match</span>';
} else if (diff.dockerCompose.status === 'different') {
statusEl.innerHTML = '<span class="diff-badge different">Files differ</span>';
} else if (diff.dockerCompose.status === 'remote-missing') {
statusEl.innerHTML = '<span class="diff-badge missing">Not deployed</span>';
} else if (diff.dockerCompose.status === 'local-missing') {
statusEl.innerHTML = '<span class="diff-badge missing">No local file</span>';
} else {
statusEl.innerHTML = '<span class="diff-badge unknown">Unknown</span>';
}
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 = `
<div class="server-item-info">
<span class="server-item-name">${server.name}</span>
<span class="server-item-host">${server.host}</span>
${server.useSudo ? '<div class="server-item-badges"><span class="server-sudo-badge">sudo</span></div>' : ''}
</div>
<div>
<button class="btn btn-small btn-secondary" onclick="editServer('${server.id}')">Edit</button>
<button class="btn btn-small btn-secondary" onclick="deleteServer('${server.id}')">Delete</button>
</div>
`;
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 => `
<div class="file-item">
<div class="file-item-icon ${file.present ? 'present' : 'missing'}">
${file.present ? '✓' : '✗'}
</div>
<div class="file-item-info">
<div class="file-item-name">${file.name}</div>
<div class="file-item-desc">${file.description}</div>
</div>
</div>
`).join('');
// Render fix instructions
const fixEl = document.getElementById('details-fix-instructions');
const missingFiles = requiredFiles.filter(f => !f.present);
if (missingFiles.length === 0) {
fixEl.innerHTML = `
<div class="fix-step">
<div class="fix-step-title">All files present!</div>
<p>This project is ready for deployment.</p>
</div>
`;
document.getElementById('btn-init-project').style.display = 'none';
} else {
fixEl.innerHTML = `
<div class="fix-step">
<div class="fix-step-title">Option 1: Use the CLI tool</div>
<p>Run this command to generate missing files:</p>
<div class="fix-step-cmd">cd "${project.path}"<br>npm run docker-deploy -- init .</div>
<div class="fix-step-note">This runs from the docker-deployment-manager repo</div>
</div>
<div class="fix-step">
<div class="fix-step-title">Option 2: Create manually</div>
<p>Missing files:</p>
${missingFiles.map(f => `
<div class="fix-step-cmd">${f.name} - ${f.description}</div>
`).join('')}
</div>
`;
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();

View File

@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">
<title>Docker Deployment Manager</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="app">
<header class="header">
<h1>Docker Deployment Manager</h1>
<div class="header-actions">
<button id="btn-refresh" class="btn btn-secondary">Refresh</button>
<button id="btn-settings" class="btn btn-secondary">Servers</button>
</div>
</header>
<div class="server-bar">
<label>Server:</label>
<select id="server-select">
<option value="">-- Select Server --</option>
</select>
<button id="btn-scan-server" class="btn btn-primary" disabled>Scan Server</button>
<span id="server-status" class="status-badge"></span>
</div>
<main class="main">
<div class="projects-table-container">
<table class="projects-table">
<thead>
<tr>
<th>Project</th>
<th>Local</th>
<th>Deployed</th>
<th>Running</th>
<th>Diff</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="projects-body">
<tr>
<td colspan="6" class="loading">Loading projects...</td>
</tr>
</tbody>
</table>
</div>
</main>
<!-- Server Modal -->
<div id="server-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Manage Servers</h2>
<button class="btn-close" id="close-server-modal">&times;</button>
</div>
<div class="modal-body">
<div id="server-list" class="server-list"></div>
<div class="server-form">
<h3>Add/Edit Server</h3>
<input type="hidden" id="server-id">
<div class="form-group">
<label>Name:</label>
<input type="text" id="server-name" placeholder="e.g., Production Server">
</div>
<div class="form-group">
<label>Host:</label>
<input type="text" id="server-host" placeholder="e.g., 192.168.69.4">
</div>
<div class="form-group">
<label>Username:</label>
<input type="text" id="server-username" placeholder="From .env (SSH_USERNAME)">
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="server-use-sudo">
Require sudo for Docker commands
</label>
</div>
<p class="hint">Username and password are read from .env file (SSH_USERNAME, SSH_PASSWORD)</p>
<button id="btn-save-server" class="btn btn-primary">Save Server</button>
</div>
</div>
</div>
</div>
<!-- Diff Modal -->
<div id="diff-modal" class="modal hidden">
<div class="modal-content modal-wide">
<div class="modal-header">
<h2>File Comparison: <span id="diff-project-name"></span></h2>
<button class="btn-close" id="close-diff-modal">&times;</button>
</div>
<div class="modal-body">
<div class="diff-section">
<h3>docker-compose.yml</h3>
<div class="diff-status" id="diff-compose-status"></div>
<div class="diff-container">
<div class="diff-panel">
<h4>Local</h4>
<pre id="diff-compose-local"></pre>
</div>
<div class="diff-panel">
<h4>Remote</h4>
<pre id="diff-compose-remote"></pre>
</div>
</div>
<div class="diff-actions">
<button id="btn-pull-compose" class="btn btn-secondary">Pull from Server</button>
<button id="btn-push-compose" class="btn btn-primary">Push to Server</button>
</div>
</div>
</div>
</div>
</div>
<!-- Log Modal -->
<div id="log-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Action Log</h2>
<button class="btn-close" id="close-log-modal">&times;</button>
</div>
<div class="modal-body">
<pre id="log-content" class="log-output"></pre>
</div>
</div>
</div>
<!-- Details Modal (Missing Files) -->
<div id="details-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Project Status: <span id="details-project-name"></span></h2>
<button class="btn-close" id="close-details-modal">&times;</button>
</div>
<div class="modal-body">
<div class="details-section">
<h3>Required Files</h3>
<div id="details-files-list" class="file-checklist"></div>
</div>
<div class="details-section">
<h3>How to Fix</h3>
<div id="details-fix-instructions" class="fix-instructions"></div>
</div>
<div class="details-actions">
<button id="btn-init-project" class="btn btn-primary">Run CLI Init</button>
</div>
</div>
</div>
</div>
<!-- Deploy Confirmation Modal -->
<div id="deploy-confirm-modal" class="modal hidden">
<div class="modal-content modal-wide">
<div class="modal-header">
<h2>Confirm Deployment: <span id="deploy-confirm-project"></span></h2>
<button class="btn-close" id="close-deploy-confirm">&times;</button>
</div>
<div class="modal-body">
<div id="deploy-diff-summary" class="deploy-diff-summary"></div>
<div id="deploy-diff-details" class="deploy-diff-details"></div>
<div class="deploy-confirm-actions">
<button id="btn-deploy-continue" class="btn btn-primary">Continue (Overwrite Remote)</button>
<button id="btn-deploy-abort" class="btn btn-secondary">Abort</button>
<button id="btn-deploy-vscode" class="btn btn-secondary">View in VS Code</button>
</div>
</div>
</div>
</div>
<!-- Container Logs Modal -->
<div id="logs-modal" class="modal hidden">
<div class="modal-content modal-wide">
<div class="modal-header">
<h2>Container Logs: <span id="logs-container-name"></span></h2>
<button class="btn-close" id="close-logs-modal">&times;</button>
</div>
<div class="modal-body">
<div class="logs-controls">
<button id="btn-refresh-logs" class="btn btn-secondary btn-small">Refresh</button>
<span class="logs-hint">Last 100 lines</span>
</div>
<pre id="logs-content" class="log-output"></pre>
</div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

View File

@@ -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;
}

View File

@@ -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"
}
}

14
app/renderer/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
<title>Docker Deployment Manager</title>
</head>
<body class="bg-background text-foreground antialiased">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

39
app/renderer/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

51
app/renderer/src/app.jsx Normal file
View File

@@ -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: <AppLayout />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: 'servers', element: <ServersPage /> },
{ path: 'projects', element: <ProjectsPage /> },
{ path: 'projects/:projectName', element: <ProjectsPage /> },
],
},
]);
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AppProvider>
<RouterProvider router={router} />
<Toaster
theme="dark"
position="bottom-right"
toastOptions={{
style: {
background: 'hsl(222, 46%, 16%)',
border: '1px solid hsl(217, 71%, 22%)',
color: 'hsl(0, 0%, 93%)',
},
}}
/>
</AppProvider>
</QueryClientProvider>
);
}

View File

@@ -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 (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-semibold">Dashboard</h1>
<p className="text-sm text-muted-foreground">Fleet health overview</p>
</div>
{/* Fleet summary */}
<div className="grid grid-cols-4 gap-4">
<SummaryCard
icon={Container}
label="Local Projects"
value={localProjects?.length ?? '-'}
/>
<SummaryCard
icon={Server}
label="Servers"
value={servers?.length ?? '-'}
/>
<SummaryCard
icon={Activity}
label="Running"
value={selectedServerId ? running : '-'}
color="green"
/>
<SummaryCard
icon={AlertCircle}
label="Stopped"
value={selectedServerId ? stopped : '-'}
color={stopped > 0 ? 'red' : 'gray'}
/>
</div>
{/* Server cards */}
<div>
<h2 className="text-lg font-medium mb-3">Servers</h2>
{!servers || servers.length === 0 ? (
<EmptyState
icon={Server}
title="No servers configured"
description="Add a server to start managing deployments"
>
<Button variant="secondary" onClick={() => navigate('/servers')}>
Add Server
</Button>
</EmptyState>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{servers.map((server) => (
<ServerHealthCard key={server.id} server={server} />
))}
</div>
)}
</div>
</div>
);
}
function SummaryCard({ icon: Icon, label, value, color }) {
return (
<Card>
<CardContent className="p-4 flex items-center gap-3">
<div className="rounded-md bg-secondary p-2">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</CardContent>
</Card>
);
}
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 (
<Card
className={isActive ? 'ring-1 ring-primary' : 'cursor-pointer hover:border-primary/50'}
onClick={() => {
setSelectedServerId(server.id);
}}
>
<CardHeader className="pb-2 p-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<StatusDot color={isActive && containers.length > 0 ? 'green' : 'gray'} />
{server.name}
</CardTitle>
{server.useSudo && (
<Badge variant="warning" className="text-[10px]">sudo</Badge>
)}
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<p className="text-xs text-muted-foreground mb-2">{server.host}</p>
{isActive ? (
<div className="flex gap-3 text-xs">
<span className="text-[hsl(var(--success))]">{running} running</span>
<span className="text-muted-foreground">{containers.length} total</span>
</div>
) : (
<p className="text-xs text-muted-foreground italic">Click to select</p>
)}
{isActive && (
<Button
variant="ghost"
size="sm"
className="mt-2 w-full text-xs"
onClick={(e) => {
e.stopPropagation();
navigate('/projects');
}}
>
View Projects
</Button>
)}
</CardContent>
</Card>
);
}

View File

@@ -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 (
<div className="space-y-4">
{/* Action buttons */}
<div className="flex gap-2 flex-wrap">
{project.path && project.hasDockerfile && (
<Button
variant="secondary"
size="sm"
onClick={handleBuild}
disabled={buildTar.isPending}
>
{buildTar.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Hammer className="h-4 w-4 mr-2" />
)}
Build
</Button>
)}
{project.path && selectedServerId && (
<Button
size="sm"
onClick={handleDeploy}
disabled={deployProject.isPending || compare.isPending}
>
{deployProject.isPending || compare.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Rocket className="h-4 w-4 mr-2" />
)}
Deploy
</Button>
)}
{project.path && project.dockerStatus !== 'configured' && (
<Button
variant="secondary"
size="sm"
onClick={handleInit}
disabled={initProject.isPending}
>
<Wrench className="h-4 w-4 mr-2" />
Init
</Button>
)}
</div>
{/* Build log */}
{buildLog && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Build Output</span>
<Button variant="ghost" size="sm" className="h-6 text-[11px]" onClick={() => setBuildLog(null)}>
Clear
</Button>
</div>
<pre className="rounded-lg bg-secondary p-3 text-[11px] leading-relaxed max-h-60 overflow-auto whitespace-pre-wrap font-mono">
{buildLog}
</pre>
</div>
)}
{/* Deploy log */}
{deployLog && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Deploy Output</span>
<Button variant="ghost" size="sm" className="h-6 text-[11px]" onClick={() => setDeployLog(null)}>
Clear
</Button>
</div>
<pre className="rounded-lg bg-secondary p-3 text-[11px] leading-relaxed max-h-60 overflow-auto whitespace-pre-wrap font-mono">
{deployLog}
</pre>
</div>
)}
{/* Deploy confirmation dialog */}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-auto">
<DialogHeader>
<DialogTitle>Confirm Deployment: {project.name}</DialogTitle>
<DialogDescription>
Differences found between local and remote files. Continuing will overwrite remote.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{(diff?.files || []).map((file) =>
file.status === 'different' || file.status === 'local-missing' ? (
<div key={file.name} className="rounded-lg border overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-secondary/50">
<span className="text-sm font-medium">{file.name}</span>
<DiffStatusBadge status={file.status} />
</div>
{(file.localContent || file.remoteContent) && (
<div className="grid grid-cols-2 divide-x text-[11px]">
<div>
<div className="px-3 py-1 text-muted-foreground uppercase bg-background">Local</div>
<pre className="px-3 py-2 max-h-40 overflow-auto whitespace-pre-wrap break-all">
{(file.sensitive ? maskEnvContent(file.localContent) : file.localContent) || '(not found)'}
</pre>
</div>
<div>
<div className="px-3 py-1 text-muted-foreground uppercase bg-background">Remote</div>
<pre className="px-3 py-2 max-h-40 overflow-auto whitespace-pre-wrap break-all">
{(file.sensitive ? maskEnvContent(file.remoteContent) : file.remoteContent) || '(not found)'}
</pre>
</div>
</div>
)}
</div>
) : null
)}
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => setConfirmOpen(false)}>
Abort
</Button>
<Button onClick={executeDeploy} disabled={deployProject.isPending}>
{deployProject.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Rocket className="h-4 w-4 mr-2" />
)}
Deploy (Overwrite Remote)
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -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 (
<div className="text-sm text-muted-foreground py-8 text-center">
Select a server to compare files
</div>
);
}
if (!project.path) {
return (
<div className="text-sm text-muted-foreground py-8 text-center">
No local project path
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button
variant="secondary"
size="sm"
onClick={handleCompare}
disabled={compare.isPending}
>
{compare.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<FileCode className="h-4 w-4 mr-2" />
)}
Compare Files
</Button>
{diff && (() => {
const pullable = (diff.files || []).filter(
(f) => f.status === 'different' || f.status === 'local-missing'
);
return pullable.length > 0 ? (
<Button
variant="secondary"
size="sm"
onClick={handlePullAll}
disabled={pullFiles.isPending}
>
<Download className="h-4 w-4 mr-2" />
Pull All Different ({pullable.length})
</Button>
) : null;
})()}
</div>
{diff && (
<div className="space-y-3">
{(diff.files || []).map((file, i) => (
<DiffFileCard
key={file.name}
file={file}
onPull={() => handlePull(file)}
onVSCodeDiff={() => handleVSCodeDiff(file)}
isPulling={pullFile.isPending}
/>
))}
{(!diff.files || diff.files.length === 0) && (
<p className="text-sm text-muted-foreground text-center py-4">No files to compare</p>
)}
</div>
)}
</div>
);
}
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 (
<div className="rounded-lg border bg-card overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-secondary/50">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{file.name}</span>
<DiffStatusBadge status={file.status} />
</div>
<div className="flex gap-1">
{(file.status === 'different' || file.status === 'local-missing') && (
<Button variant="ghost" size="sm" onClick={onPull} disabled={isPulling}>
<Download className="h-3.5 w-3.5 mr-1" />
Pull
</Button>
)}
{file.status === 'different' && file.type !== 'directory' && (
<Button variant="ghost" size="sm" onClick={onVSCodeDiff}>
<ExternalLink className="h-3.5 w-3.5 mr-1" />
VS Code
</Button>
)}
</div>
</div>
{hasContent && file.status !== 'match' && (
<div className="grid grid-cols-2 divide-x">
<div className="p-0">
<div className="px-3 py-1 text-[11px] text-muted-foreground uppercase bg-background">
Local
</div>
<pre className="px-3 py-2 text-[11px] leading-relaxed max-h-52 overflow-auto whitespace-pre-wrap break-all">
{localDisplay || '(not found)'}
</pre>
</div>
<div className="p-0">
<div className="px-3 py-1 text-[11px] text-muted-foreground uppercase bg-background">
Remote
</div>
<pre className="px-3 py-2 text-[11px] leading-relaxed max-h-52 overflow-auto whitespace-pre-wrap break-all">
{remoteDisplay || '(not found)'}
</pre>
</div>
</div>
)}
</div>
);
}

View File

@@ -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 (
<TooltipProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-auto">
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
</main>
</div>
</TooltipProvider>
);
}

View File

@@ -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 (
<aside className="flex flex-col w-56 shrink-0 border-r bg-card h-full">
{/* App title */}
<div className="flex items-center gap-2 px-4 h-14 border-b">
<Container className="h-5 w-5 text-primary" />
<span className="font-semibold text-sm">Docker Deploy</span>
</div>
{/* Navigation */}
<nav className="flex flex-col gap-1 p-2">
{navItems.map(({ to, icon: Icon, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground'
)
}
>
<Icon className="h-4 w-4" />
{label}
</NavLink>
))}
</nav>
<Separator className="mx-2" />
{/* Server selector */}
<div className="p-3">
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
Active Server
</label>
<select
value={selectedServerId || ''}
onChange={(e) => setSelectedServerId(e.target.value || null)}
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value="">None selected</option>
{(servers || []).map((server) => (
<option key={server.id} value={server.id}>
{server.name}
</option>
))}
</select>
</div>
{/* Footer */}
<div className="mt-auto p-3 border-t">
<p className="text-[11px] text-muted-foreground">v1.0.0</p>
</div>
</aside>
);
}

View File

@@ -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 (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-semibold flex items-center gap-2">
{project.name}
<RunningStatusBadge running={project.running} deployed={project.deployed} />
</h2>
{project.path && (
<p className="text-xs text-muted-foreground mt-1">{project.path}</p>
)}
{project.running?.ports && (
<p className="text-xs text-muted-foreground mt-0.5">Ports: {project.running.ports}</p>
)}
</div>
<ProjectStatusBadge project={project} />
</div>
{/* Tabs */}
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4 mt-4">
{/* File checklist */}
{project.path && (
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<FileCode className="h-4 w-4" />
Required Files
</h3>
<div className="space-y-2">
{getRequiredFiles(project).map((file) => (
<div key={file.name} className="flex items-center gap-2 text-sm">
{file.present ? (
<Check className="h-4 w-4 text-[hsl(var(--success))]" />
) : (
<X className="h-4 w-4 text-destructive" />
)}
<span className="font-mono text-xs">{file.name}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Status cards */}
<div className="grid grid-cols-3 gap-3">
<StatusCard
label="Deployed"
value={
!selectedServerId
? 'Select server'
: project.deployed
? 'Yes'
: 'No'
}
color={project.deployed ? 'green' : 'gray'}
/>
<StatusCard
label="Running"
value={
!selectedServerId
? '-'
: project.running
? project.running.status?.split(' ')[0]
: project.deployed
? 'Stopped'
: '-'
}
color={
project.running?.status?.includes('Up')
? 'green'
: project.deployed
? 'red'
: 'gray'
}
/>
<StatusCard
label="Tar File"
value={project.tarFile ? 'Built' : 'Not built'}
color={project.tarFile ? 'green' : 'gray'}
/>
</div>
{/* Deploy actions */}
<DeployActions project={project} />
</TabsContent>
<TabsContent value="files" className="mt-4">
<DiffViewer project={project} />
</TabsContent>
<TabsContent value="logs" className="mt-4">
<LogsPanel project={project} />
</TabsContent>
</Tabs>
</div>
);
}
function StatusCard({ label, value, color }) {
return (
<Card>
<CardContent className="p-3">
<p className="text-[11px] text-muted-foreground mb-1">{label}</p>
<div className="flex items-center gap-2">
<StatusDot color={color} />
<span className="text-sm font-medium">{value}</span>
</div>
</CardContent>
</Card>
);
}

View File

@@ -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 (
<button
onClick={onClick}
className={cn(
'w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors',
isSelected
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground'
)}
>
<StatusDot color={statusColor} />
<div className="flex-1 min-w-0">
<div className="font-medium truncate text-foreground">{project.name}</div>
{project.deployed && (
<div className="flex gap-1 mt-0.5">
{project.running?.status?.includes('Up') ? (
<Badge variant="success" className="text-[10px] px-1.5 py-0">active</Badge>
) : project.deployed ? (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">exited</Badge>
) : null}
</div>
)}
</div>
</button>
);
}

View File

@@ -0,0 +1,33 @@
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
export function ProjectSearch({ value, onChange, statusFilter, onStatusFilterChange }) {
return (
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={value}
onChange={(e) => onChange(e.target.value)}
className="pl-9 h-9"
/>
</div>
<div className="flex gap-1">
{['all', 'running', 'stopped', 'not-deployed'].map((filter) => (
<button
key={filter}
onClick={() => onStatusFilterChange(filter)}
className={`px-2 py-0.5 text-[11px] rounded-md transition-colors ${
statusFilter === filter
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{filter === 'all' ? 'All' : filter === 'running' ? 'Running' : filter === 'stopped' ? 'Stopped' : 'Not Deployed'}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { Badge } from '@/components/ui/badge';
export function ProjectStatusBadge({ project }) {
if (!project.path) {
return <Badge variant="secondary">Remote only</Badge>;
}
const missing = getRequiredFiles(project).filter((f) => !f.present).length;
if (missing === 0) {
return <Badge variant="success">Ready</Badge>;
}
if (missing <= 2) {
return <Badge variant="warning">{missing} missing</Badge>;
}
return <Badge variant="destructive">{missing} missing</Badge>;
}
export function RunningStatusBadge({ running, deployed }) {
if (running) {
const isUp = running.status?.includes('Up');
return (
<Badge variant={isUp ? 'success' : 'destructive'}>
{isUp ? 'Running' : 'Stopped'}
</Badge>
);
}
if (deployed) {
return <Badge variant="destructive">Stopped</Badge>;
}
return null;
}
export function DiffStatusBadge({ status }) {
switch (status) {
case 'match':
return <Badge variant="success">Match</Badge>;
case 'different':
return <Badge variant="warning">Different</Badge>;
case 'remote-missing':
return <Badge variant="destructive">Not on server</Badge>;
case 'local-missing':
return <Badge variant="warning">Only on server</Badge>;
case 'both-exist':
return <Badge variant="success">Both exist</Badge>;
case 'neither':
return <Badge variant="secondary">Neither</Badge>;
default:
return <Badge variant="secondary">Unknown</Badge>;
}
}
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 },
];
}

View File

@@ -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 (
<div className="flex h-full">
{/* Left panel — project list */}
<div className="w-80 shrink-0 border-r flex flex-col h-full">
<div className="p-3 border-b">
<ProjectSearch
value={search}
onChange={setSearch}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
/>
</div>
<div className="p-2 border-b">
<span className="text-[11px] text-muted-foreground px-1">
{filtered.length} project{filtered.length !== 1 ? 's' : ''}
{isLoading ? ' (loading...)' : ''}
</span>
</div>
<ScrollArea className="flex-1">
<div className="p-1 space-y-0.5">
{filtered.map((project) => (
<ProjectListItem
key={project.name}
project={project}
isSelected={project.name === selectedProject?.name}
onClick={() => navigate(`/projects/${encodeURIComponent(project.name)}`)}
/>
))}
{filtered.length === 0 && !isLoading && (
<div className="py-8 text-center text-sm text-muted-foreground">
No projects found
</div>
)}
</div>
</ScrollArea>
</div>
{/* Right panel — project detail */}
<div className="flex-1 overflow-auto">
{selectedProject ? (
<ProjectDetail project={selectedProject} />
) : (
<EmptyState
icon={Container}
title="Select a project"
description="Choose a project from the list to view its details, compare files, and manage deployments"
className="h-full"
/>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Settings</h1>
<p className="text-sm text-muted-foreground">Servers and project configuration</p>
</div>
<Button onClick={handleAdd}>
<Plus className="h-4 w-4 mr-2" />
Add Server
</Button>
</div>
{/* Projects Root Path */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<FolderOpen className="h-4 w-4" />
Projects Root
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
The folder containing your project repositories. The app scans subdirectories (up to 2 levels deep) for Dockerfiles.
</p>
<div className="flex gap-2">
<Input
value={projectsRoot || config?.projectsRoot || ''}
onChange={(e) => setProjectsRoot(e.target.value)}
placeholder="e.g., C:\.bucket\repos.gitea"
className="font-mono text-xs"
/>
<Button
variant="secondary"
size="sm"
onClick={handleSaveRoot}
disabled={saveConfig.isPending || !currentRoot || currentRoot === config?.projectsRoot}
>
<Save className="h-4 w-4 mr-1" />
Save
</Button>
</div>
</CardContent>
</Card>
<Separator />
{/* Servers */}
<div>
<h2 className="text-lg font-medium mb-3">Servers</h2>
{!servers || servers.length === 0 ? (
<EmptyState
icon={Server}
title="No servers yet"
description="Add your first server to start deploying Docker containers"
>
<Button onClick={handleAdd}>
<Plus className="h-4 w-4 mr-2" />
Add Server
</Button>
</EmptyState>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{servers.map((server) => (
<Card key={server.id}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">{server.name}</CardTitle>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(server)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => handleDelete(server.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{server.host}</p>
{server.username && (
<p className="text-xs text-muted-foreground mt-1">User: {server.username}</p>
)}
<div className="flex gap-2 mt-2">
{server.useSudo && <Badge variant="warning">sudo</Badge>}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
<ServerDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
server={editingServer}
onSave={handleSave}
isPending={saveServer.isPending}
/>
</div>
);
}
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{server ? 'Edit Server' : 'Add Server'}</DialogTitle>
<DialogDescription>
SSH credentials are read from the .env file (SSH_USERNAME, SSH_PASSWORD)
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
placeholder="e.g., Production Server"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="host">Host</Label>
<Input
id="host"
placeholder="e.g., 192.168.69.4"
value={host}
onChange={(e) => setHost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username (optional)</Label>
<Input
id="username"
placeholder="From .env (SSH_USERNAME)"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="sudo"
checked={useSudo}
onCheckedChange={setUseSudo}
/>
<Label htmlFor="sudo" className="text-sm font-normal">
Require sudo for Docker commands
</Label>
</div>
<DialogFooter>
<Button type="submit" disabled={isPending || !name || !host}>
{isPending ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,12 @@
import { cn } from '@/lib/utils';
export function EmptyState({ icon: Icon, title, description, children, className }) {
return (
<div className={cn('flex flex-col items-center justify-center py-16 text-center', className)}>
{Icon && <Icon className="h-12 w-12 text-muted-foreground/50 mb-4" />}
{title && <h3 className="text-lg font-medium text-foreground mb-1">{title}</h3>}
{description && <p className="text-sm text-muted-foreground max-w-sm">{description}</p>}
{children && <div className="mt-4">{children}</div>}
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col items-center justify-center py-16 text-center">
<AlertTriangle className="h-12 w-12 text-destructive mb-4" />
<h3 className="text-lg font-medium mb-1">Something went wrong</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<Button
variant="secondary"
onClick={() => this.setState({ hasError: false, error: null })}
>
Try Again
</Button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -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 (
<span
className={cn('inline-block h-2.5 w-2.5 rounded-full', colorMap[color] || colorMap.gray, className)}
/>
);
}

View File

@@ -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 <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -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 (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,38 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -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) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-3.5 w-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -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) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/70 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,16 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Label = React.forwardRef(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className
)}
{...props}
/>
));
Label.displayName = 'Label';
export { Label };

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef(
({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,43 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,61 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 230 40% 14%;
--foreground: 0 0% 93%;
--card: 222 46% 16%;
--card-foreground: 0 0% 93%;
--popover: 222 46% 16%;
--popover-foreground: 0 0% 93%;
--primary: 349 78% 58%;
--primary-foreground: 0 0% 100%;
--secondary: 217 71% 22%;
--secondary-foreground: 0 0% 93%;
--muted: 222 30% 20%;
--muted-foreground: 0 0% 53%;
--accent: 217 71% 22%;
--accent-foreground: 0 0% 93%;
--destructive: 349 78% 58%;
--destructive-foreground: 0 0% 100%;
--border: 217 71% 22%;
--input: 217 71% 22%;
--ring: 349 78% 58%;
--radius: 0.5rem;
/* Semantic status colors */
--success: 180 80% 25%;
--success-foreground: 0 0% 100%;
--warning: 37 90% 58%;
--warning-foreground: 0 0% 0%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: hsl(var(--background));
}
::-webkit-scrollbar-thumb {
background: hsl(var(--secondary));
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground));
}
}

View File

@@ -0,0 +1,14 @@
import { useMutation } from '@tanstack/react-query';
import { projectApi } from '@/lib/api';
export function useCompareProject() {
return useMutation({
mutationFn: (data) => projectApi.compare(data),
});
}
export function useInitProject() {
return useMutation({
mutationFn: (projectPath) => projectApi.init(projectPath),
});
}

View File

@@ -0,0 +1,23 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { configApi } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
export function useConfig() {
return useQuery({
queryKey: queryKeys.config.all,
queryFn: () => configApi.get(),
staleTime: Infinity,
});
}
export function useSaveConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (config) => configApi.save(config),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.config.all });
// Also refresh projects since root path may have changed
queryClient.invalidateQueries({ queryKey: queryKeys.projects.local });
},
});
}

View File

@@ -0,0 +1,24 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deployApi } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
export function useBuildTar() {
return useMutation({
mutationFn: (projectPath) => deployApi.buildTar(projectPath),
});
}
export function useDeployProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => deployApi.deploy(data),
onSuccess: (_result, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.projects.deployed(variables.serverId),
});
queryClient.invalidateQueries({
queryKey: queryKeys.projects.containers(variables.serverId),
});
},
});
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { logsApi } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
export function useContainerLogs(serverId, remotePath, options = {}) {
return useQuery({
queryKey: queryKeys.logs.container(serverId, remotePath),
queryFn: () =>
logsApi.getContainerLogs({
serverId,
remotePath,
lines: options.lines || 100,
}),
enabled: !!serverId && !!remotePath,
refetchInterval: options.autoRefresh ? 5_000 : false,
staleTime: 0,
});
}

View File

@@ -0,0 +1,59 @@
import { useMemo } from 'react';
import { useLocalProjects, useDeployedProjects, useRunningContainers } from './use-projects';
export function useMergedProjects(serverId) {
const { data: localProjects, isLoading: localLoading } = useLocalProjects();
const { data: deployedResult, isLoading: deployedLoading } = useDeployedProjects(serverId);
const { data: containersResult, isLoading: containersLoading } = useRunningContainers(serverId);
const projects = useMemo(() => {
const projectMap = new Map();
// Add local projects
if (localProjects) {
for (const project of localProjects) {
projectMap.set(project.name, {
...project,
deployed: null,
running: null,
});
}
}
// Match with deployed projects
const deployed = deployedResult?.deployed || [];
for (const dep of deployed) {
if (projectMap.has(dep.name)) {
projectMap.get(dep.name).deployed = dep;
} else {
projectMap.set(dep.name, {
name: dep.name,
path: null,
hasDockerfile: false,
hasDockerCompose: false,
dockerStatus: 'remote-only',
deployed: dep,
running: null,
});
}
}
// Match with running containers
const containers = containersResult?.containers || [];
for (const container of containers) {
for (const [name, project] of projectMap) {
if (container.name.includes(name) || name.includes(container.name)) {
project.running = container;
break;
}
}
}
return Array.from(projectMap.values());
}, [localProjects, deployedResult, containersResult]);
return {
projects,
isLoading: localLoading || (serverId && deployedLoading) || (serverId && containersLoading),
};
}

View File

@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { projectApi } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
export function useLocalProjects() {
return useQuery({
queryKey: queryKeys.projects.local,
queryFn: () => projectApi.scanLocal(),
staleTime: 30_000,
});
}
export function useDeployedProjects(serverId) {
return useQuery({
queryKey: queryKeys.projects.deployed(serverId),
queryFn: () => projectApi.scanServer(serverId),
enabled: !!serverId,
staleTime: 60_000,
});
}
export function useRunningContainers(serverId) {
return useQuery({
queryKey: queryKeys.projects.containers(serverId),
queryFn: () => projectApi.getRunningContainers(serverId),
enabled: !!serverId,
refetchInterval: 30_000,
staleTime: 10_000,
});
}

View File

@@ -0,0 +1,31 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { serverApi } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
export function useServers() {
return useQuery({
queryKey: queryKeys.servers.all,
queryFn: () => serverApi.getAll(),
staleTime: Infinity,
});
}
export function useSaveServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (server) => serverApi.save(server),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
},
});
}
export function useDeleteServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (serverId) => serverApi.delete(serverId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
},
});
}

View File

@@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { syncApi } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
export function usePullFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => syncApi.pullFile(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.local });
},
});
}
export function usePullFiles() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => syncApi.pullFiles(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.local });
},
});
}

View File

@@ -0,0 +1,38 @@
const api = window.api;
export const serverApi = {
getAll: () => api.getServers(),
save: (server) => api.saveServer(server),
delete: (id) => api.deleteServer(id),
};
export const projectApi = {
scanLocal: () => api.scanLocalProjects(),
scanServer: (serverId) => api.scanServer(serverId),
getRunningContainers: (serverId) => api.getRunningContainers(serverId),
compare: (data) => api.compareProject(data),
init: (projectPath) => api.initProject(projectPath),
};
export const deployApi = {
buildTar: (projectPath) => api.buildTar(projectPath),
deploy: (data) => api.deployProject(data),
};
export const syncApi = {
pullFile: (data) => api.pullFile(data),
pullFiles: (data) => api.pullFiles(data),
};
export const logsApi = {
getContainerLogs: (data) => api.getContainerLogs(data),
};
export const configApi = {
get: () => api.getConfig(),
save: (config) => api.saveConfig(config),
};
export const toolsApi = {
openVSCodeDiff: (data) => api.openVSCodeDiff(data),
};

View File

@@ -0,0 +1,21 @@
import { createContext, useContext, useState } from 'react';
const AppContext = createContext(null);
export function AppProvider({ children }) {
const [selectedServerId, setSelectedServerId] = useState(null);
return (
<AppContext.Provider value={{ selectedServerId, setSelectedServerId }}>
{children}
</AppContext.Provider>
);
}
export function useAppContext() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useAppContext must be used within AppProvider');
}
return context;
}

View File

@@ -0,0 +1,16 @@
export const queryKeys = {
servers: {
all: ['servers'],
},
projects: {
local: ['projects', 'local'],
deployed: (serverId) => ['projects', 'deployed', serverId],
containers: (serverId) => ['projects', 'containers', serverId],
},
logs: {
container: (serverId, remotePath) => ['logs', serverId, remotePath],
},
config: {
all: ['config'],
},
};

View File

@@ -0,0 +1,24 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
export function escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
export function maskEnvContent(content) {
if (!content) return null;
return content.replace(
/^([A-Z_]+PASSWORD|[A-Z_]+SECRET|[A-Z_]+KEY|[A-Z_]+TOKEN)=(.+)$/gm,
'$1=****'
);
}

10
app/renderer/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './app';
import './globals.css';
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,50 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [require('tailwindcss-animate')],
};

View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
root: '.',
base: './',
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
target: 'chrome130',
},
server: {
port: 5173,
strictPort: true,
},
});

76
cli/local-dev.ps1 Normal file
View File

@@ -0,0 +1,76 @@
# local-dev.ps1 — Build and run any app locally with docker compose
# Usage: .\local-dev.ps1 [-Path <project-dir>] [-Down] [-Logs] [-Build]
#
# Examples:
# .\local-dev.ps1 -Path C:\.bucket\repos.gitea\actual.budget.report
# .\local-dev.ps1 -Path C:\.bucket\repos.gitea\dotrepo.audiosphere\Projects\AudioSphere -Build
# .\local-dev.ps1 -Path C:\.bucket\repos.gitea\dotrepo.audioevents\AudioEvents -Logs
# .\local-dev.ps1 -Path C:\.bucket\repos.gitea\dotrepo.audioevents\AudioEvents -Down
param(
[Parameter(Mandatory=$true)]
[string]$Path,
[switch]$Build,
[switch]$Down,
[switch]$Logs
)
$ErrorActionPreference = "Stop"
# Resolve and validate path
$ProjectDir = Resolve-Path $Path -ErrorAction Stop
$ComposePath = Join-Path $ProjectDir "docker-compose.yml"
if (-not (Test-Path $ComposePath)) {
Write-Host "ERROR: No docker-compose.yml found in $ProjectDir" -ForegroundColor Red
exit 1
}
Write-Host "Project: $ProjectDir" -ForegroundColor Cyan
Push-Location $ProjectDir
try {
if ($Down) {
Write-Host "`nStopping containers..." -ForegroundColor Yellow
docker compose down
Write-Host "Done." -ForegroundColor Green
exit 0
}
if ($Logs) {
Write-Host "`nShowing logs (Ctrl+C to stop)..." -ForegroundColor Yellow
docker compose logs -f
exit 0
}
# Build and start
Write-Host "`nBuilding and starting..." -ForegroundColor Yellow
if ($Build) {
docker compose build --no-cache
}
docker compose up -d --build --force-recreate
Write-Host "`nContainers:" -ForegroundColor Cyan
docker compose ps
# Extract port from docker-compose.yml
$portLine = Select-String -Path $ComposePath -Pattern '(\d+):(\d+)' | Select-Object -First 1
if ($portLine -match '"?\$\{HOST_PORT:-(\d+)\}:(\d+)"?') {
$hostPort = $Matches[1]
Write-Host "`nApp running at: http://localhost:$hostPort" -ForegroundColor Green
} elseif ($portLine -match '"?(\d+):(\d+)"?') {
$hostPort = $Matches[1]
Write-Host "`nApp running at: http://localhost:$hostPort" -ForegroundColor Green
}
Write-Host "`nUseful commands:" -ForegroundColor DarkGray
Write-Host " Logs: .\local-dev.ps1 -Path '$Path' -Logs" -ForegroundColor DarkGray
Write-Host " Stop: .\local-dev.ps1 -Path '$Path' -Down" -ForegroundColor DarkGray
Write-Host " Rebuild: .\local-dev.ps1 -Path '$Path' -Build" -ForegroundColor DarkGray
}
finally {
Pop-Location
}

View File

@@ -33,7 +33,11 @@ const DEFAULT_CONFIG = {
sshKeyPath: '', sshKeyPath: '',
targetPath: '', targetPath: '',
autoLoad: true, autoLoad: true,
autoStart: true autoStart: true,
// Files to upload during deploy (relative to project root)
// Default: tar, docker-compose.yml, .env
// Can add: data/, configs/, etc.
uploadFiles: []
} }
}; };