coolify integration.
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(ssh:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
820
README.md
@@ -1,139 +1,703 @@
|
|||||||
# Docker Deployment Manager
|
# Docker Deployment Manager → Coolify Migration
|
||||||
|
|
||||||
Automated Docker deployment system for containerizing and deploying projects from Windows to Linux.
|
This project started as a custom Docker deployment tool (CLI + Electron desktop app) for deploying from Windows to Linux servers via SSH/tar transfers. After evaluating the approach, we're migrating to **Coolify** — an open-source self-hosted PaaS that handles the entire deploy pipeline automatically.
|
||||||
|
|
||||||
## Overview
|
## Why Coolify Instead of a Custom Tool?
|
||||||
|
|
||||||
**Phase 1 (Current):** CLI tool to generate Dockerfiles, docker-compose, and deployment scripts.
|
| Custom Tool | Coolify |
|
||||||
|
|---|---|
|
||||||
|
| Manual button clicks to deploy | Auto-deploys on `git push` |
|
||||||
|
| SSH password stored in plaintext `.env` | SSH keys / deploy keys |
|
||||||
|
| Tar-based image transfer (no registry) | Builds on server, no transfer needed |
|
||||||
|
| Desktop app required on Windows | Web dashboard from any browser |
|
||||||
|
| Custom file-diff comparison | Git is the source of truth |
|
||||||
|
| One developer maintaining it | Thousands of contributors |
|
||||||
|
|
||||||
**Phase 2 (Planned):** Electron desktop app for central management and visibility across all projects.
|
## Architecture
|
||||||
|
|
||||||
## Quick Start
|
```
|
||||||
|
Windows (dev machine) 192.168.69.4 (box-stableapps)
|
||||||
|
┌──────────────────────┐ ┌──────────────────────────────┐
|
||||||
|
│ C:\.bucket\repos.gitea│ │ Coolify (port 2010) │
|
||||||
|
│ │ │ ├── Dashboard + DB │
|
||||||
|
│ edit code │ │ ├── Traefik proxy │
|
||||||
|
│ → git commit │ │ └── Manages deployments │
|
||||||
|
│ → git push │ │ │
|
||||||
|
│ │ │ │ Gitea (ports 2003/2004) │
|
||||||
|
│ ▼ │ │ ├── HTTP: 2003 │
|
||||||
|
│ Gitea (webhook) ────────────► │ └── SSH: 2004 │
|
||||||
|
│ gitea.clintmasden │ │ │
|
||||||
|
│ .duckdns.org │ │ Data: ~/containers/*/data/ │
|
||||||
|
└──────────────────────┘ └──────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ SSH (root)
|
||||||
|
▼
|
||||||
|
192.168.69.5 (box-repoapps)
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Deployed apps: │
|
||||||
|
│ ├── learnxiny │
|
||||||
|
│ ├── actualbudget.report │
|
||||||
|
│ └── ... │
|
||||||
|
│ │
|
||||||
|
│ Data: ~/containers/*/data/ │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
|
||||||
```powershell
|
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
5
app/.env.example
Normal 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
150
app/README.md
Normal 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
626
app/main/index.js
Normal 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
123
app/main/project-scanner.js
Normal 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
280
app/main/server-scanner.js
Normal 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
196
app/main/ssh-service.js
Normal 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
22
app/package.json
Normal 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
33
app/preload.js
Normal 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
922
app/renderer-legacy/app.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDeployment() {
|
||||||
|
const project = state.currentDeployProject;
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
hideModal(deployConfirmModal);
|
||||||
|
showLog(`Deploying ${project.name}...\n`);
|
||||||
|
|
||||||
|
const result = await window.api.deployProject({
|
||||||
|
projectPath: project.path,
|
||||||
|
serverId: state.selectedServerId,
|
||||||
|
remotePath: project.remotePath || `~/containers/${project.name}`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
appendLog(`\nError: ${result.error}`);
|
||||||
|
} else {
|
||||||
|
// Show uploaded files
|
||||||
|
if (result.uploadedFiles && result.uploadedFiles.length > 0) {
|
||||||
|
appendLog(`\nUploaded files:\n - ${result.uploadedFiles.join('\n - ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLog(`\n\n${result.message || 'Deployment complete!'}`);
|
||||||
|
if (result.status) {
|
||||||
|
appendLog(`\n\nContainer status:\n${result.status}`);
|
||||||
|
}
|
||||||
|
if (result.healthy) {
|
||||||
|
appendLog('\n\nHealth check: PASSED');
|
||||||
|
} else {
|
||||||
|
appendLog('\n\nHealth check: PENDING (container may still be starting)');
|
||||||
|
}
|
||||||
|
// Refresh server state
|
||||||
|
await scanServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.currentDeployProject = null;
|
||||||
|
state.currentDeployDiff = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openVSCodeDiff() {
|
||||||
|
const project = state.currentDeployProject;
|
||||||
|
const diff = state.currentDeployDiff;
|
||||||
|
if (!project || !diff) return;
|
||||||
|
|
||||||
|
// Open docker-compose.yml diff in VS Code
|
||||||
|
if (diff.dockerCompose.status === 'different') {
|
||||||
|
const result = await window.api.openVSCodeDiff({
|
||||||
|
localPath: `${project.path}/docker-compose.yml`,
|
||||||
|
serverId: state.selectedServerId,
|
||||||
|
remoteFilePath: `${project.remotePath || '~/containers/' + project.name}/docker-compose.yml`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
alert(`Error opening VS Code: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showDiff(projectName) {
|
||||||
|
const project = state.localProjects.find(p => p.name === projectName);
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
state.currentDiffProject = project;
|
||||||
|
document.getElementById('diff-project-name').textContent = projectName;
|
||||||
|
|
||||||
|
const result = await window.api.compareProject({
|
||||||
|
projectPath: project.path,
|
||||||
|
serverId: state.selectedServerId,
|
||||||
|
remotePath: project.remotePath || `~/containers/${projectName}`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
alert(`Error comparing: ${result.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = result.diff;
|
||||||
|
|
||||||
|
// docker-compose status
|
||||||
|
const statusEl = document.getElementById('diff-compose-status');
|
||||||
|
if (diff.dockerCompose.status === 'match') {
|
||||||
|
statusEl.innerHTML = '<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();
|
||||||
193
app/renderer-legacy/index.html
Normal file
193
app/renderer-legacy/index.html
Normal 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">×</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">×</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">×</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">×</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">×</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">×</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>
|
||||||
652
app/renderer-legacy/styles.css
Normal file
652
app/renderer-legacy/styles.css
Normal 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;
|
||||||
|
}
|
||||||
16
app/renderer/components.json
Normal file
16
app/renderer/components.json
Normal 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
14
app/renderer/index.html
Normal 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
39
app/renderer/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/renderer/postcss.config.js
Normal file
6
app/renderer/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
51
app/renderer/src/app.jsx
Normal file
51
app/renderer/src/app.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
app/renderer/src/components/dashboard/dashboard-page.jsx
Normal file
151
app/renderer/src/components/dashboard/dashboard-page.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
app/renderer/src/components/deploy/deploy-button.jsx
Normal file
262
app/renderer/src/components/deploy/deploy-button.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
app/renderer/src/components/diff/diff-viewer.jsx
Normal file
207
app/renderer/src/components/diff/diff-viewer.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
app/renderer/src/components/layout/app-layout.jsx
Normal file
19
app/renderer/src/components/layout/app-layout.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
app/renderer/src/components/layout/sidebar.jsx
Normal file
75
app/renderer/src/components/layout/sidebar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
app/renderer/src/components/projects/project-detail.jsx
Normal file
134
app/renderer/src/components/projects/project-detail.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
app/renderer/src/components/projects/project-list-item.jsx
Normal file
37
app/renderer/src/components/projects/project-list-item.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
app/renderer/src/components/projects/project-search.jsx
Normal file
33
app/renderer/src/components/projects/project-search.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
];
|
||||||
|
}
|
||||||
101
app/renderer/src/components/projects/projects-page.jsx
Normal file
101
app/renderer/src/components/projects/projects-page.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
app/renderer/src/components/servers/servers-page.jsx
Normal file
271
app/renderer/src/components/servers/servers-page.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
app/renderer/src/components/shared/empty-state.jsx
Normal file
12
app/renderer/src/components/shared/empty-state.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
app/renderer/src/components/shared/error-boundary.jsx
Normal file
36
app/renderer/src/components/shared/error-boundary.jsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/renderer/src/components/shared/status-dot.jsx
Normal file
16
app/renderer/src/components/shared/status-dot.jsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
app/renderer/src/components/ui/badge.jsx
Normal file
27
app/renderer/src/components/ui/badge.jsx
Normal 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 };
|
||||||
46
app/renderer/src/components/ui/button.jsx
Normal file
46
app/renderer/src/components/ui/button.jsx
Normal 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 };
|
||||||
38
app/renderer/src/components/ui/card.jsx
Normal file
38
app/renderer/src/components/ui/card.jsx
Normal 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 };
|
||||||
22
app/renderer/src/components/ui/checkbox.jsx
Normal file
22
app/renderer/src/components/ui/checkbox.jsx
Normal 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 };
|
||||||
83
app/renderer/src/components/ui/dialog.jsx
Normal file
83
app/renderer/src/components/ui/dialog.jsx
Normal 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,
|
||||||
|
};
|
||||||
19
app/renderer/src/components/ui/input.jsx
Normal file
19
app/renderer/src/components/ui/input.jsx
Normal 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 };
|
||||||
16
app/renderer/src/components/ui/label.jsx
Normal file
16
app/renderer/src/components/ui/label.jsx
Normal 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 };
|
||||||
37
app/renderer/src/components/ui/scroll-area.jsx
Normal file
37
app/renderer/src/components/ui/scroll-area.jsx
Normal 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 };
|
||||||
22
app/renderer/src/components/ui/separator.jsx
Normal file
22
app/renderer/src/components/ui/separator.jsx
Normal 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 };
|
||||||
43
app/renderer/src/components/ui/tabs.jsx
Normal file
43
app/renderer/src/components/ui/tabs.jsx
Normal 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 };
|
||||||
22
app/renderer/src/components/ui/tooltip.jsx
Normal file
22
app/renderer/src/components/ui/tooltip.jsx
Normal 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 };
|
||||||
61
app/renderer/src/globals.css
Normal file
61
app/renderer/src/globals.css
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/renderer/src/hooks/use-compare.js
Normal file
14
app/renderer/src/hooks/use-compare.js
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
23
app/renderer/src/hooks/use-config.js
Normal file
23
app/renderer/src/hooks/use-config.js
Normal 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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
24
app/renderer/src/hooks/use-deploy.js
Normal file
24
app/renderer/src/hooks/use-deploy.js
Normal 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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
18
app/renderer/src/hooks/use-logs.js
Normal file
18
app/renderer/src/hooks/use-logs.js
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
59
app/renderer/src/hooks/use-merged-projects.js
Normal file
59
app/renderer/src/hooks/use-merged-projects.js
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
30
app/renderer/src/hooks/use-projects.js
Normal file
30
app/renderer/src/hooks/use-projects.js
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
31
app/renderer/src/hooks/use-servers.js
Normal file
31
app/renderer/src/hooks/use-servers.js
Normal 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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
23
app/renderer/src/hooks/use-sync.js
Normal file
23
app/renderer/src/hooks/use-sync.js
Normal 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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
38
app/renderer/src/lib/api.js
Normal file
38
app/renderer/src/lib/api.js
Normal 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),
|
||||||
|
};
|
||||||
21
app/renderer/src/lib/app-context.jsx
Normal file
21
app/renderer/src/lib/app-context.jsx
Normal 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;
|
||||||
|
}
|
||||||
16
app/renderer/src/lib/query-keys.js
Normal file
16
app/renderer/src/lib/query-keys.js
Normal 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'],
|
||||||
|
},
|
||||||
|
};
|
||||||
24
app/renderer/src/lib/utils.js
Normal file
24
app/renderer/src/lib/utils.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
10
app/renderer/src/main.jsx
Normal 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>
|
||||||
|
);
|
||||||
50
app/renderer/tailwind.config.js
Normal file
50
app/renderer/tailwind.config.js
Normal 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')],
|
||||||
|
};
|
||||||
23
app/renderer/vite.config.js
Normal file
23
app/renderer/vite.config.js
Normal 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
76
cli/local-dev.ps1
Normal 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
|
||||||
|
}
|
||||||
@@ -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: []
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user