Add Coolify REST API server with Scalar docs and UI integration
Express API server on :3100 exposing all Coolify operations: - CRUD for apps, env vars, servers - Full upsert pipeline (create/update + env + route + deploy) - Drift detection, Traefik route management via SSH - Scalar API docs at /reference, OpenAPI 3.1 spec UI: New Coolify page with app cards, deploy/delete actions, env var expansion. Sidebar nav + React Query hooks + fetch client. Both UI and LLM/CLI use the same HTTP endpoints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
24
api/lib/config.js
Normal file
24
api/lib/config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Shared config from gitea.repo.management
|
||||
const CONFIG_PATH = join(__dirname, '..', '..', '..', 'gitea.repo.management', 'config.json');
|
||||
|
||||
let _cached = null;
|
||||
|
||||
export function loadConfig() {
|
||||
if (_cached) return _cached;
|
||||
if (!existsSync(CONFIG_PATH)) {
|
||||
throw new Error(`Config not found at ${CONFIG_PATH}`);
|
||||
}
|
||||
_cached = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
||||
return _cached;
|
||||
}
|
||||
|
||||
export function reloadConfig() {
|
||||
_cached = null;
|
||||
return loadConfig();
|
||||
}
|
||||
28
api/lib/coolify-client.js
Normal file
28
api/lib/coolify-client.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Authenticated fetch wrapper for the Coolify API.
|
||||
* Ported from gitea.repo.management/electron/main.cjs:166-183
|
||||
*/
|
||||
export async function coolifyFetch(config, apiPath, options = {}) {
|
||||
const base = (config.coolify?.apiUrl || '').replace(/\/$/, '');
|
||||
const token = config.coolify?.apiToken || '';
|
||||
const url = `${base}/api/v1${apiPath}`;
|
||||
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
};
|
||||
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(`Coolify ${options.method || 'GET'} ${apiPath} failed: ${res.status} ${text}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) return res.json();
|
||||
return res.text();
|
||||
}
|
||||
45
api/lib/ssh.js
Normal file
45
api/lib/ssh.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Client } from 'ssh2';
|
||||
|
||||
/**
|
||||
* Execute a command on the remote server via SSH.
|
||||
* Uses the ssh2 library (already a project dependency) instead of
|
||||
* the plink/sshpass fallback chain from gitea.repo.management.
|
||||
*/
|
||||
export function sshExec(config, command) {
|
||||
const host = config.coolify?.sshHost;
|
||||
const user = config.coolify?.sshUser;
|
||||
const password = config.coolify?.sshPassword;
|
||||
|
||||
if (!host || !user) {
|
||||
return Promise.reject(new Error('SSH not configured — set coolify.sshHost and coolify.sshUser in config.json'));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
|
||||
conn.on('ready', () => {
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) { conn.end(); return reject(err); }
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
stream.on('close', (code) => {
|
||||
conn.end();
|
||||
if (code !== 0 && stderr) {
|
||||
reject(new Error(stderr.trim()));
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('data', (data) => { stdout += data.toString(); });
|
||||
stream.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
});
|
||||
});
|
||||
|
||||
conn.on('error', (err) => reject(err));
|
||||
|
||||
conn.connect({ host, port: 22, username: user, password });
|
||||
});
|
||||
}
|
||||
329
api/openapi.json
Normal file
329
api/openapi.json
Normal file
@@ -0,0 +1,329 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Coolify Deployment API",
|
||||
"description": "REST API for managing Coolify applications, Traefik routing, and automated deployments. Used by both the desktop UI and LLM agents.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"servers": [
|
||||
{ "url": "http://localhost:3100", "description": "Local dev" }
|
||||
],
|
||||
"paths": {
|
||||
"/api/coolify/apps": {
|
||||
"get": {
|
||||
"operationId": "listApps",
|
||||
"summary": "List all Coolify apps",
|
||||
"description": "Returns all applications from Coolify, enriched with HOST_PORT env vars and env list.",
|
||||
"tags": ["Apps"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Array of Coolify applications",
|
||||
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/App" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createApp",
|
||||
"summary": "Create a new Coolify application",
|
||||
"tags": ["Apps"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CreateAppRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Created app object" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/coolify/apps/find/{name}": {
|
||||
"get": {
|
||||
"operationId": "findApp",
|
||||
"summary": "Find app by name",
|
||||
"tags": ["Apps"],
|
||||
"parameters": [
|
||||
{ "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "App object or null" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/coolify/apps/{uuid}": {
|
||||
"patch": {
|
||||
"operationId": "updateApp",
|
||||
"summary": "Update an existing app",
|
||||
"tags": ["Apps"],
|
||||
"parameters": [
|
||||
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "type": "object" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Updated app" }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "deleteApp",
|
||||
"summary": "Delete an app from Coolify",
|
||||
"tags": ["Apps"],
|
||||
"parameters": [
|
||||
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Deletion confirmation" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/coolify/apps/{uuid}/envs": {
|
||||
"get": {
|
||||
"operationId": "listEnvs",
|
||||
"summary": "List env vars for an app",
|
||||
"tags": ["Environment"],
|
||||
"parameters": [
|
||||
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Array of env vars" }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "setEnv",
|
||||
"summary": "Set an environment variable",
|
||||
"tags": ["Environment"],
|
||||
"parameters": [
|
||||
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": { "type": "string" },
|
||||
"value": { "type": "string" }
|
||||
},
|
||||
"required": ["key", "value"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Env var created/updated" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/coolify/apps/{uuid}/deploy": {
|
||||
"post": {
|
||||
"operationId": "deployApp",
|
||||
"summary": "Trigger deployment for an app",
|
||||
"tags": ["Deploy"],
|
||||
"parameters": [
|
||||
{ "name": "uuid", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Deployment triggered" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/coolify/servers": {
|
||||
"get": {
|
||||
"operationId": "listServers",
|
||||
"summary": "List all Coolify servers",
|
||||
"tags": ["Servers"],
|
||||
"responses": {
|
||||
"200": { "description": "Array of servers" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/coolify/next-port": {
|
||||
"get": {
|
||||
"operationId": "getNextPort",
|
||||
"summary": "Get next available HOST_PORT",
|
||||
"description": "Scans Coolify env vars and Traefik config to find the highest used port, returns max+1.",
|
||||
"tags": ["Infrastructure"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Next port info",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nextPort": { "type": "integer" },
|
||||
"usedPorts": { "type": "array", "items": { "type": "integer" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/coolify/routes": {
|
||||
"post": {
|
||||
"operationId": "addRoute",
|
||||
"summary": "Add a Traefik route via SSH",
|
||||
"tags": ["Infrastructure"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"routeName": { "type": "string", "description": "Traefik service/router name (alphanumeric)" },
|
||||
"domain": { "type": "string", "description": "Domain name (e.g. timer.dotrepo.com)" },
|
||||
"port": { "type": "integer", "description": "HOST_PORT on target server" }
|
||||
},
|
||||
"required": ["routeName", "domain", "port"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Route added or already exists" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/coolify/drift": {
|
||||
"post": {
|
||||
"operationId": "checkDrift",
|
||||
"summary": "Check drift between coolify.json and live Coolify state",
|
||||
"tags": ["Deploy"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projectPath": { "type": "string", "description": "Absolute path to project directory" },
|
||||
"appName": { "type": "string", "description": "Optional app name when coolify.json has multiple entries" }
|
||||
},
|
||||
"required": ["projectPath"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Drift check result with diffs" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/coolify/upsert": {
|
||||
"post": {
|
||||
"operationId": "upsertApp",
|
||||
"summary": "Full deploy pipeline: read config → create/update → env → route → deploy",
|
||||
"description": "Reads coolify.json from the project, creates or updates the Coolify app, sets HOST_PORT, configures Traefik routing, writes changelog, and triggers deployment. This is the primary endpoint for deploying apps.",
|
||||
"tags": ["Deploy"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projectPath": { "type": "string", "description": "Absolute path to project directory containing coolify.json" },
|
||||
"appName": { "type": "string", "description": "Optional app name when coolify.json has multiple entries" }
|
||||
},
|
||||
"required": ["projectPath"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Upsert result with steps and changelog",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/UpsertResult" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/coolify/config": {
|
||||
"get": {
|
||||
"operationId": "readCoolifyConfig",
|
||||
"summary": "Read coolify.json from a project directory",
|
||||
"tags": ["Config"],
|
||||
"parameters": [
|
||||
{ "name": "path", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Absolute path to project directory" }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Parsed coolify.json contents" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/health": {
|
||||
"get": {
|
||||
"operationId": "healthCheck",
|
||||
"summary": "Health check",
|
||||
"tags": ["System"],
|
||||
"responses": {
|
||||
"200": { "description": "Server status" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"App": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uuid": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"build_pack": { "type": "string", "enum": ["dockercompose", "nixpacks", "dockerfile"] },
|
||||
"git_repository": { "type": "string" },
|
||||
"git_branch": { "type": "string" },
|
||||
"status": { "type": "string" },
|
||||
"fqdn": { "type": "string" },
|
||||
"_host_port": { "type": "string", "nullable": true },
|
||||
"_envs": { "type": "array" }
|
||||
}
|
||||
},
|
||||
"CreateAppRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "App name in Coolify" },
|
||||
"buildpack": { "type": "string", "enum": ["dockercompose", "nixpacks"], "default": "dockercompose" },
|
||||
"gitRepo": { "type": "string", "description": "Git SSH URL" },
|
||||
"gitBranch": { "type": "string", "default": "master" },
|
||||
"isStatic": { "type": "boolean", "default": false },
|
||||
"publishDir": { "type": "string", "default": "dist" },
|
||||
"portsExposes": { "type": "string", "default": "3000" },
|
||||
"baseDirectory": { "type": "string", "default": "/" },
|
||||
"dockerComposeLocation": { "type": "string", "default": "/docker-compose.yml" },
|
||||
"serverUuid": { "type": "string", "description": "Override server UUID" }
|
||||
},
|
||||
"required": ["name", "gitRepo"]
|
||||
},
|
||||
"UpsertResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"uuid": { "type": "string" },
|
||||
"domain": { "type": "string" },
|
||||
"changelogEntry": { "type": "object" },
|
||||
"steps": { "type": "array", "items": { "$ref": "#/components/schemas/Step" } }
|
||||
}
|
||||
},
|
||||
"Step": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"step": { "type": "string" },
|
||||
"status": { "type": "string", "enum": ["running", "done", "error", "skipped", "warn"] },
|
||||
"detail": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
502
api/routes/coolify.js
Normal file
502
api/routes/coolify.js
Normal file
@@ -0,0 +1,502 @@
|
||||
import { Router } from 'express';
|
||||
import { readFileSync, existsSync, writeFileSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { coolifyFetch } from '../lib/coolify-client.js';
|
||||
import { sshExec } from '../lib/ssh.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function readCoolifyJson(projectPath) {
|
||||
const cfgPath = join(projectPath, 'coolify.json');
|
||||
if (!existsSync(cfgPath)) return null;
|
||||
const parsed = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
}
|
||||
|
||||
async function resolveServer(config, serverName) {
|
||||
if (!serverName) {
|
||||
return { uuid: config.coolify?.serverUuid, ip: config.coolify?.targetIp || '192.168.69.5', isLocal: false };
|
||||
}
|
||||
const servers = await coolifyFetch(config, '/servers');
|
||||
const match = servers.find(s => s.name === serverName);
|
||||
if (!match) throw new Error(`Server "${serverName}" not found in Coolify. Available: ${servers.map(s => s.name).join(', ')}`);
|
||||
const isLocal = match.ip === 'host.docker.internal' || match.name === 'localhost';
|
||||
const ip = isLocal ? (config.coolify?.sshHost || '192.168.69.4') : match.ip;
|
||||
return { uuid: match.uuid, ip, isLocal, name: match.name };
|
||||
}
|
||||
|
||||
function wrap(fn) {
|
||||
return async (req, res) => {
|
||||
try {
|
||||
const result = await fn(req, res);
|
||||
if (!res.headersSent) res.json(result);
|
||||
} catch (err) {
|
||||
res.status(err.status || 500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Routes ────────────────────────────────────────────────────────
|
||||
|
||||
// GET /apps — list all Coolify apps (enriched with HOST_PORT)
|
||||
router.get('/apps', wrap(async () => {
|
||||
const config = loadConfig();
|
||||
const apps = await coolifyFetch(config, '/applications');
|
||||
const enriched = await Promise.all(apps.map(async (app) => {
|
||||
try {
|
||||
const envs = await coolifyFetch(config, `/applications/${app.uuid}/envs`);
|
||||
const hostPort = envs.find(e => e.key === 'HOST_PORT');
|
||||
return { ...app, _host_port: hostPort ? hostPort.value : null, _envs: envs };
|
||||
} catch {
|
||||
return { ...app, _host_port: null, _envs: [] };
|
||||
}
|
||||
}));
|
||||
return enriched;
|
||||
}));
|
||||
|
||||
// GET /apps/find/:name — find app by name
|
||||
router.get('/apps/find/:name', wrap(async (req) => {
|
||||
const config = loadConfig();
|
||||
const apps = await coolifyFetch(config, '/applications');
|
||||
return apps.find(a => a.name === req.params.name) || null;
|
||||
}));
|
||||
|
||||
// POST /apps — create a new Coolify application
|
||||
router.post('/apps', wrap(async (req) => {
|
||||
const config = loadConfig();
|
||||
const { name, buildpack, gitRepo, gitBranch, isStatic, publishDir, portsExposes, baseDirectory, dockerComposeLocation, serverUuid } = req.body;
|
||||
const body = {
|
||||
project_uuid: config.coolify?.projectUuid,
|
||||
server_uuid: serverUuid || config.coolify?.serverUuid,
|
||||
environment_name: 'production',
|
||||
name,
|
||||
git_repository: gitRepo,
|
||||
git_branch: gitBranch || 'master',
|
||||
build_pack: buildpack || 'nixpacks',
|
||||
ports_exposes: portsExposes || '3000',
|
||||
instant_deploy: false,
|
||||
private_key_uuid: config.coolify?.privateKeyUuid || 'j0w08woc8s8c0sgok8ccow4w',
|
||||
base_directory: baseDirectory || '/',
|
||||
};
|
||||
if (buildpack === 'dockercompose') {
|
||||
body.docker_compose_location = dockerComposeLocation || '/docker-compose.yml';
|
||||
}
|
||||
if (isStatic) {
|
||||
body.is_static = true;
|
||||
body.publish_directory = publishDir || 'dist';
|
||||
}
|
||||
return coolifyFetch(config, '/applications/private-deploy-key', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}));
|
||||
|
||||
// PATCH /apps/:uuid — update an existing app
|
||||
router.patch('/apps/:uuid', wrap(async (req) => {
|
||||
const config = loadConfig();
|
||||
return coolifyFetch(config, `/applications/${req.params.uuid}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(req.body)
|
||||
});
|
||||
}));
|
||||
|
||||
// DELETE /apps/:uuid — delete an app
|
||||
router.delete('/apps/:uuid', wrap(async (req) => {
|
||||
const config = loadConfig();
|
||||
return coolifyFetch(config, `/applications/${req.params.uuid}`, { method: 'DELETE' });
|
||||
}));
|
||||
|
||||
// GET /apps/:uuid/envs — list env vars for an app
|
||||
router.get('/apps/:uuid/envs', wrap(async (req) => {
|
||||
const config = loadConfig();
|
||||
return coolifyFetch(config, `/applications/${req.params.uuid}/envs`);
|
||||
}));
|
||||
|
||||
// POST /apps/:uuid/envs — set an env var
|
||||
router.post('/apps/:uuid/envs', wrap(async (req) => {
|
||||
const config = loadConfig();
|
||||
const { key, value } = req.body;
|
||||
return coolifyFetch(config, `/applications/${req.params.uuid}/envs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, value, is_preview: false })
|
||||
});
|
||||
}));
|
||||
|
||||
// POST /apps/:uuid/deploy — trigger deployment
|
||||
router.post('/apps/:uuid/deploy', wrap(async (req) => {
|
||||
const config = loadConfig();
|
||||
return coolifyFetch(config, `/deploy?uuid=${req.params.uuid}&force=true`);
|
||||
}));
|
||||
|
||||
// GET /servers — list all Coolify servers
|
||||
router.get('/servers', wrap(async () => {
|
||||
const config = loadConfig();
|
||||
return coolifyFetch(config, '/servers');
|
||||
}));
|
||||
|
||||
// GET /next-port — get next available HOST_PORT
|
||||
router.get('/next-port', wrap(async () => {
|
||||
const config = loadConfig();
|
||||
const ports = [];
|
||||
|
||||
try {
|
||||
const apps = await coolifyFetch(config, '/applications');
|
||||
for (const app of apps) {
|
||||
try {
|
||||
const envs = await coolifyFetch(config, `/applications/${app.uuid}/envs`);
|
||||
const hp = envs.find(e => e.key === 'HOST_PORT');
|
||||
if (hp && hp.value) ports.push(parseInt(hp.value, 10));
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
|
||||
try {
|
||||
const traefikPath = config.coolify?.traefikPath;
|
||||
const raw = await sshExec(config, `sudo cat "${traefikPath}"`);
|
||||
const portMatches = raw.match(/http:\/\/192\.168\.69\.\d+:(\d+)/g) || [];
|
||||
for (const m of portMatches) {
|
||||
const p = parseInt(m.match(/:(\d+)$/)[1], 10);
|
||||
if (!ports.includes(p)) ports.push(p);
|
||||
}
|
||||
} catch { /* SSH may not be configured */ }
|
||||
|
||||
const maxPort = ports.length > 0 ? Math.max(...ports) : 2006;
|
||||
return { nextPort: maxPort + 1, usedPorts: ports.sort((a, b) => a - b) };
|
||||
}));
|
||||
|
||||
// POST /routes — add a Traefik route via SSH
|
||||
router.post('/routes', wrap(async (req) => {
|
||||
const config = loadConfig();
|
||||
const { routeName, domain, port } = req.body;
|
||||
const traefikPath = config.coolify?.traefikPath;
|
||||
const targetIp = config.coolify?.targetIp || '192.168.69.5';
|
||||
|
||||
const current = await sshExec(config, `sudo cat "${traefikPath}"`);
|
||||
|
||||
const routerBlock = [
|
||||
` ${routeName}:`,
|
||||
` rule: "Host(\`${domain}\`)"`,
|
||||
` service: ${routeName}`,
|
||||
` entryPoints: [websecure]`,
|
||||
` tls:`,
|
||||
` certResolver: letsencrypt`
|
||||
].join('\n');
|
||||
|
||||
const serviceBlock = [
|
||||
` ${routeName}:`,
|
||||
` loadBalancer:`,
|
||||
` servers:`,
|
||||
` - url: "http://${targetIp}:${port}"`
|
||||
].join('\n');
|
||||
|
||||
if (current.includes(`${routeName}:`) && current.includes(`${targetIp}:${port}`)) {
|
||||
return { added: false, reason: 'route_exists' };
|
||||
}
|
||||
|
||||
let newYml;
|
||||
const servicesSplit = current.split(' services:');
|
||||
if (servicesSplit.length === 2) {
|
||||
newYml = servicesSplit[0].trimEnd() + '\n' + routerBlock + '\n\n services:' + servicesSplit[1].trimEnd() + '\n' + serviceBlock + '\n';
|
||||
} else {
|
||||
newYml = current.trimEnd() + '\n' + routerBlock + '\n' + serviceBlock + '\n';
|
||||
}
|
||||
|
||||
const b64 = Buffer.from(newYml).toString('base64');
|
||||
await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`);
|
||||
return { added: true };
|
||||
}));
|
||||
|
||||
// POST /drift — check drift between coolify.json and live Coolify state
|
||||
router.post('/drift', wrap(async (req) => {
|
||||
const config = loadConfig();
|
||||
const { projectPath, appName } = req.body;
|
||||
|
||||
const configs = readCoolifyJson(projectPath);
|
||||
if (!configs || configs.length === 0) return { exists: false, diffs: [], configs: [] };
|
||||
|
||||
const appConfig = appName ? configs.find(c => c.name === appName) : configs[0];
|
||||
if (!appConfig) return { exists: false, diffs: [], configs };
|
||||
|
||||
const apps = await coolifyFetch(config, '/applications');
|
||||
const app = apps.find(a => a.name === appConfig.name);
|
||||
if (!app) return { exists: false, diffs: [], appConfig, configs };
|
||||
|
||||
const diffs = [];
|
||||
const compare = (field, localVal, remoteVal, label) => {
|
||||
if (String(localVal || '') !== String(remoteVal || '')) {
|
||||
diffs.push({ field, label, local: localVal, remote: remoteVal });
|
||||
}
|
||||
};
|
||||
|
||||
compare('buildpack', appConfig.buildpack, app.build_pack, 'Buildpack');
|
||||
compare('branch', appConfig.branch || 'master', app.git_branch, 'Branch');
|
||||
compare('baseDirectory', appConfig.baseDirectory || '/', app.base_directory, 'Base directory');
|
||||
|
||||
if (appConfig.buildpack === 'dockercompose') {
|
||||
compare('dockerComposeLocation', appConfig.dockerComposeLocation || '/docker-compose.yml', app.docker_compose_location, 'Compose file');
|
||||
}
|
||||
if (appConfig.buildpack === 'nixpacks') {
|
||||
compare('static', appConfig.static ? 'true' : 'false', app.is_static ? 'true' : 'false', 'Static site');
|
||||
if (appConfig.static) {
|
||||
compare('publishDir', appConfig.publishDir || 'dist', app.publish_directory, 'Publish directory');
|
||||
}
|
||||
}
|
||||
|
||||
const repoName = appConfig.repo || basename(projectPath);
|
||||
const expectedGitRepo = `git@${config.coolify?.sshHost}:2004/${config.owner}/${repoName}.git`;
|
||||
compare('repo', expectedGitRepo, app.git_repository, 'Git repository');
|
||||
|
||||
try {
|
||||
const envs = await coolifyFetch(config, `/applications/${app.uuid}/envs`);
|
||||
const hostPort = envs.find(e => e.key === 'HOST_PORT');
|
||||
if (hostPort) {
|
||||
compare('port', String(appConfig.port), hostPort.value, 'HOST_PORT');
|
||||
} else if (appConfig.port) {
|
||||
diffs.push({ field: 'port', label: 'HOST_PORT', local: String(appConfig.port), remote: '(not set)' });
|
||||
}
|
||||
} catch { /* envs may not be available */ }
|
||||
|
||||
if (appConfig.domain && app.fqdn) {
|
||||
const expectedFqdn = `https://${appConfig.domain}`;
|
||||
compare('domain', expectedFqdn, app.fqdn, 'Domain (FQDN)');
|
||||
}
|
||||
|
||||
return { exists: true, app: { uuid: app.uuid, name: app.name }, diffs, appConfig, configs };
|
||||
}));
|
||||
|
||||
// GET /config?path=<projectPath> — read coolify.json from a project directory
|
||||
router.get('/config', wrap(async (req) => {
|
||||
const projectPath = req.query.path;
|
||||
if (!projectPath) throw Object.assign(new Error('Missing "path" query parameter'), { status: 400 });
|
||||
return readCoolifyJson(projectPath) || { error: 'No coolify.json found' };
|
||||
}));
|
||||
|
||||
// POST /upsert — full pipeline: read config → find/create app → env → route → deploy
|
||||
router.post('/upsert', wrap(async (req) => {
|
||||
const config = loadConfig();
|
||||
const { projectPath, appName } = req.body;
|
||||
const steps = [];
|
||||
const changelogChanges = [];
|
||||
|
||||
const step = (name, status, detail) => {
|
||||
const idx = steps.findIndex(s => s.step === name);
|
||||
const entry = { step: name, status, detail };
|
||||
if (idx >= 0) steps[idx] = entry; else steps.push(entry);
|
||||
};
|
||||
|
||||
// Step 1: Read coolify.json
|
||||
step('config', 'running', 'Reading coolify.json...');
|
||||
const configs = readCoolifyJson(projectPath);
|
||||
if (!configs || configs.length === 0) throw new Error('No coolify.json found in project');
|
||||
const appConfig = appName ? configs.find(c => c.name === appName) : configs[0];
|
||||
if (!appConfig) throw new Error(`App "${appName}" not found in coolify.json`);
|
||||
|
||||
const repoName = appConfig.repo || basename(projectPath);
|
||||
const gitRepo = `git@${config.coolify?.sshHost}:2004/${config.owner}/${repoName}.git`;
|
||||
const branch = appConfig.branch || 'master';
|
||||
const baseDir = appConfig.baseDirectory || '/';
|
||||
const composeLocation = appConfig.dockerComposeLocation || '/docker-compose.yml';
|
||||
|
||||
step('config', 'done', `name=${appConfig.name} buildpack=${appConfig.buildpack} port=${appConfig.port}`);
|
||||
|
||||
// Step 2: Resolve server
|
||||
step('server', 'running', `Resolving server "${appConfig.server || 'default'}"...`);
|
||||
const server = await resolveServer(config, appConfig.server);
|
||||
step('server', 'done', `uuid=${server.uuid} ip=${server.ip} local=${server.isLocal}`);
|
||||
|
||||
// Step 3: Check if app exists
|
||||
step('check', 'running', 'Searching Coolify applications...');
|
||||
const apps = await coolifyFetch(config, '/applications');
|
||||
let existing = apps.find(a => a.name === appConfig.name);
|
||||
|
||||
if (existing) {
|
||||
const detail = await coolifyFetch(config, `/applications/${existing.uuid}`);
|
||||
existing = detail;
|
||||
step('check', 'done', `FOUND — will UPDATE uuid=${existing.uuid}`);
|
||||
} else {
|
||||
step('check', 'done', `NOT FOUND — will CREATE`);
|
||||
}
|
||||
|
||||
// Step 4: Validate
|
||||
step('validate', 'running', 'Comparing config...');
|
||||
if (existing) {
|
||||
const check = (field, localVal, remoteVal, label) => {
|
||||
if (String(localVal ?? '') !== String(remoteVal ?? '')) {
|
||||
changelogChanges.push({ field, label, old: remoteVal, new: localVal });
|
||||
}
|
||||
};
|
||||
check('build_pack', appConfig.buildpack, existing.build_pack, 'Buildpack');
|
||||
check('git_repository', gitRepo, existing.git_repository, 'Git repository');
|
||||
check('git_branch', branch, existing.git_branch, 'Branch');
|
||||
check('base_directory', baseDir, existing.base_directory, 'Base directory');
|
||||
if (appConfig.buildpack === 'dockercompose') {
|
||||
check('docker_compose_location', composeLocation, existing.docker_compose_location, 'Compose file');
|
||||
}
|
||||
if (appConfig.buildpack === 'nixpacks') {
|
||||
check('is_static', appConfig.static || false, existing.is_static || false, 'Static site');
|
||||
if (appConfig.static) check('publish_directory', appConfig.publishDir || 'dist', existing.publish_directory, 'Publish dir');
|
||||
}
|
||||
}
|
||||
step('validate', 'done', changelogChanges.length > 0 ? `${changelogChanges.length} field(s) will change` : existing ? 'No changes needed' : 'New app');
|
||||
|
||||
// Step 5: Create or Update
|
||||
let appUuid;
|
||||
if (existing) {
|
||||
step('sync', 'running', `Updating ${existing.uuid}...`);
|
||||
const updates = {
|
||||
build_pack: appConfig.buildpack,
|
||||
git_repository: gitRepo,
|
||||
git_branch: branch,
|
||||
base_directory: baseDir,
|
||||
is_static: appConfig.static || false,
|
||||
publish_directory: appConfig.publishDir || (appConfig.static ? 'dist' : '/'),
|
||||
ports_exposes: '3000'
|
||||
};
|
||||
if (appConfig.buildpack === 'dockercompose') updates.docker_compose_location = composeLocation;
|
||||
await coolifyFetch(config, `/applications/${existing.uuid}`, { method: 'PATCH', body: JSON.stringify(updates) });
|
||||
appUuid = existing.uuid;
|
||||
step('sync', 'done', `UPDATED ${appUuid}`);
|
||||
} else {
|
||||
step('sync', 'running', 'Creating new app...');
|
||||
const body = {
|
||||
project_uuid: config.coolify?.projectUuid,
|
||||
server_uuid: server.uuid,
|
||||
environment_name: 'production',
|
||||
name: appConfig.name,
|
||||
git_repository: gitRepo,
|
||||
git_branch: branch,
|
||||
build_pack: appConfig.buildpack || 'nixpacks',
|
||||
base_directory: baseDir,
|
||||
ports_exposes: '3000',
|
||||
instant_deploy: false,
|
||||
private_key_uuid: config.coolify?.privateKeyUuid || 'j0w08woc8s8c0sgok8ccow4w'
|
||||
};
|
||||
if (appConfig.buildpack === 'dockercompose') body.docker_compose_location = composeLocation;
|
||||
const created = await coolifyFetch(config, '/applications/private-deploy-key', { method: 'POST', body: JSON.stringify(body) });
|
||||
appUuid = created.uuid;
|
||||
changelogChanges.push({ field: '_action', label: 'Action', old: null, new: 'CREATED' });
|
||||
|
||||
// Post-create PATCH
|
||||
const postPatch = {};
|
||||
if (appConfig.static) {
|
||||
postPatch.is_static = true;
|
||||
postPatch.publish_directory = appConfig.publishDir || '/dist';
|
||||
}
|
||||
if (appConfig.buildpack !== 'dockercompose') postPatch.domains = '';
|
||||
if (Object.keys(postPatch).length > 0) {
|
||||
await coolifyFetch(config, `/applications/${appUuid}`, { method: 'PATCH', body: JSON.stringify(postPatch) });
|
||||
}
|
||||
step('sync', 'done', `CREATED ${appUuid}`);
|
||||
}
|
||||
|
||||
// Step 6: Set HOST_PORT env var
|
||||
step('env', 'running', `Setting HOST_PORT=${appConfig.port}...`);
|
||||
let existingEnvs = [];
|
||||
try { existingEnvs = await coolifyFetch(config, `/applications/${appUuid}/envs`); } catch { /* ok */ }
|
||||
const existingHostPort = existingEnvs.find(e => e.key === 'HOST_PORT');
|
||||
|
||||
if (existingHostPort && existingHostPort.value === String(appConfig.port)) {
|
||||
step('env', 'done', `HOST_PORT=${appConfig.port} (unchanged)`);
|
||||
} else {
|
||||
if (existingHostPort) {
|
||||
changelogChanges.push({ field: 'HOST_PORT', label: 'HOST_PORT', old: existingHostPort.value, new: String(appConfig.port) });
|
||||
try {
|
||||
await coolifyFetch(config, `/applications/${appUuid}/envs/${existingHostPort.id}`, {
|
||||
method: 'PATCH', body: JSON.stringify({ key: 'HOST_PORT', value: String(appConfig.port), is_preview: false })
|
||||
});
|
||||
} catch {
|
||||
try { await coolifyFetch(config, `/applications/${appUuid}/envs/${existingHostPort.id}`, { method: 'DELETE' }); } catch { /* ok */ }
|
||||
await coolifyFetch(config, `/applications/${appUuid}/envs`, {
|
||||
method: 'POST', body: JSON.stringify({ key: 'HOST_PORT', value: String(appConfig.port), is_preview: false })
|
||||
});
|
||||
}
|
||||
} else {
|
||||
changelogChanges.push({ field: 'HOST_PORT', label: 'HOST_PORT', old: null, new: String(appConfig.port) });
|
||||
await coolifyFetch(config, `/applications/${appUuid}/envs`, {
|
||||
method: 'POST', body: JSON.stringify({ key: 'HOST_PORT', value: String(appConfig.port), is_preview: false })
|
||||
});
|
||||
}
|
||||
step('env', 'done', `HOST_PORT=${appConfig.port} (set)`);
|
||||
}
|
||||
|
||||
// Step 7: Routing
|
||||
if (!appConfig.domain) {
|
||||
step('route', 'skipped', 'No domain configured');
|
||||
} else if (server.isLocal) {
|
||||
await coolifyFetch(config, `/applications/${appUuid}`, {
|
||||
method: 'PATCH', body: JSON.stringify({ fqdn: `https://${appConfig.domain}` })
|
||||
});
|
||||
step('route', 'done', `FQDN set: https://${appConfig.domain}`);
|
||||
} else {
|
||||
if (appConfig.buildpack !== 'dockercompose') {
|
||||
try { await coolifyFetch(config, `/applications/${appUuid}`, { method: 'PATCH', body: JSON.stringify({ domains: '' }) }); } catch { /* ok */ }
|
||||
}
|
||||
|
||||
const routeName = appConfig.name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||||
const traefikPath = config.coolify?.traefikPath;
|
||||
const currentYml = await sshExec(config, `sudo cat "${traefikPath}"`);
|
||||
|
||||
if (currentYml.includes(appConfig.domain)) {
|
||||
step('route', 'done', `Traefik route already exists for ${appConfig.domain}`);
|
||||
} else {
|
||||
const routerBlock = [
|
||||
` ${routeName}:`,
|
||||
` rule: "Host(\`${appConfig.domain}\`)"`,
|
||||
` service: ${routeName}`,
|
||||
` entryPoints: [websecure]`,
|
||||
` tls:`,
|
||||
` certResolver: letsencrypt`,
|
||||
].join('\n');
|
||||
const serviceBlock = [
|
||||
` ${routeName}:`,
|
||||
` loadBalancer:`,
|
||||
` servers:`,
|
||||
` - url: "http://${server.ip}:${appConfig.port}"`,
|
||||
].join('\n');
|
||||
|
||||
let newYml;
|
||||
const servicesSplit = currentYml.split(' services:');
|
||||
if (servicesSplit.length === 2) {
|
||||
newYml = servicesSplit[0].trimEnd() + '\n' + routerBlock + '\n\n services:' + servicesSplit[1].trimEnd() + '\n' + serviceBlock + '\n';
|
||||
} else {
|
||||
newYml = currentYml.trimEnd() + '\n' + routerBlock + '\n' + serviceBlock + '\n';
|
||||
}
|
||||
|
||||
const b64 = Buffer.from(newYml).toString('base64');
|
||||
await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`);
|
||||
changelogChanges.push({ field: 'traefik_route', label: 'Traefik route', old: null, new: `${appConfig.domain} → ${server.ip}:${appConfig.port}` });
|
||||
step('route', 'done', `Route added: ${appConfig.domain} → ${server.ip}:${appConfig.port}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: Changelog
|
||||
step('changelog', 'running', 'Writing changelog...');
|
||||
const changelogPath = join(projectPath, 'coolify.changelog.json');
|
||||
let changelog = [];
|
||||
if (existsSync(changelogPath)) {
|
||||
try { changelog = JSON.parse(readFileSync(changelogPath, 'utf8')); } catch { changelog = []; }
|
||||
}
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action: existing ? 'update' : 'create',
|
||||
appName: appConfig.name,
|
||||
uuid: appUuid,
|
||||
changes: changelogChanges,
|
||||
configSnapshot: { ...appConfig },
|
||||
};
|
||||
changelog.push(entry);
|
||||
writeFileSync(changelogPath, JSON.stringify(changelog, null, 2), 'utf8');
|
||||
step('changelog', 'done', `${changelogChanges.length} change(s) logged`);
|
||||
|
||||
// Step 9: Deploy
|
||||
step('deploy', 'running', 'Triggering deployment...');
|
||||
const deployResult = await coolifyFetch(config, `/deploy?uuid=${appUuid}&force=true`);
|
||||
step('deploy', 'done', `Deployment triggered — ${appConfig.domain ? `https://${appConfig.domain}` : appUuid}`);
|
||||
|
||||
return { success: true, uuid: appUuid, domain: appConfig.domain, changelogEntry: entry, steps };
|
||||
}));
|
||||
|
||||
export default router;
|
||||
33
api/server.js
Normal file
33
api/server.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { apiReference } from '@scalar/express-api-reference';
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import coolifyRoutes from './routes/coolify.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const spec = JSON.parse(readFileSync(join(__dirname, 'openapi.json'), 'utf8'));
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3100;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// API routes
|
||||
app.use('/api/coolify', coolifyRoutes);
|
||||
|
||||
// OpenAPI spec
|
||||
app.get('/openapi.json', (req, res) => res.json(spec));
|
||||
|
||||
// Scalar API docs
|
||||
app.use('/reference', apiReference({ spec: { url: '/openapi.json' } }));
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`API server running on http://localhost:${PORT}`);
|
||||
console.log(`Scalar docs at http://localhost:${PORT}/reference`);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
import { CoolifyPage } from '@/components/coolify/coolify-page';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -25,6 +26,7 @@ const router = createHashRouter([
|
||||
{ path: 'servers', element: <ServersPage /> },
|
||||
{ path: 'projects', element: <ProjectsPage /> },
|
||||
{ path: 'projects/:projectName', element: <ProjectsPage /> },
|
||||
{ path: 'coolify', element: <CoolifyPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
92
app/renderer/src/components/coolify/coolify-app-card.jsx
Normal file
92
app/renderer/src/components/coolify/coolify-app-card.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import { Rocket, Trash2, ChevronDown, ChevronRight, ExternalLink } 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';
|
||||
|
||||
const buildpackColor = {
|
||||
dockercompose: 'secondary',
|
||||
nixpacks: 'outline',
|
||||
dockerfile: 'outline',
|
||||
};
|
||||
|
||||
function statusColor(status) {
|
||||
if (!status) return 'gray';
|
||||
const s = status.toLowerCase();
|
||||
if (s.includes('running') || s.includes('healthy')) return 'green';
|
||||
if (s.includes('stopped') || s.includes('exited')) return 'red';
|
||||
if (s.includes('building') || s.includes('starting')) return 'yellow';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
export function CoolifyAppCard({ app, onDeploy, onDelete }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<StatusDot color={statusColor(app.status)} />
|
||||
{app.name}
|
||||
</CardTitle>
|
||||
<Badge variant={buildpackColor[app.build_pack] || 'outline'} className="text-[10px]">
|
||||
{app.build_pack}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 pt-0 space-y-2">
|
||||
{/* Port + domain */}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{app._host_port && <span>:{app._host_port}</span>}
|
||||
{app.git_branch && <span>{app.git_branch}</span>}
|
||||
{app.fqdn && (
|
||||
<a
|
||||
href={app.fqdn}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{app.fqdn.replace(/^https?:\/\//, '')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1" onClick={() => onDeploy(app)}>
|
||||
<Rocket className="h-3 w-3" /> Deploy
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1 text-destructive" onClick={() => onDelete(app)}>
|
||||
<Trash2 className="h-3 w-3" /> Delete
|
||||
</Button>
|
||||
<button
|
||||
className="ml-auto text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
Envs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expandable env vars */}
|
||||
{expanded && app._envs && (
|
||||
<div className="rounded-md bg-secondary/50 p-2 text-[11px] font-mono space-y-0.5 max-h-40 overflow-auto">
|
||||
{app._envs.length === 0 && <span className="text-muted-foreground">No env vars</span>}
|
||||
{app._envs.map((env) => (
|
||||
<div key={env.id || env.key} className="flex gap-2">
|
||||
<span className="text-primary">{env.key}</span>
|
||||
<span className="text-muted-foreground">=</span>
|
||||
<span className="truncate">{env.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
133
app/renderer/src/components/coolify/coolify-deploy-dialog.jsx
Normal file
133
app/renderer/src/components/coolify/coolify-deploy-dialog.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle2, Circle, Loader2, XCircle, AlertTriangle, SkipForward } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const STEP_LABELS = {
|
||||
config: 'Read coolify.json',
|
||||
server: 'Resolve server',
|
||||
check: 'Check app existence',
|
||||
validate: 'Validate config',
|
||||
sync: 'Create / Update app',
|
||||
env: 'Set HOST_PORT',
|
||||
route: 'Configure routing',
|
||||
changelog: 'Write changelog',
|
||||
deploy: 'Trigger deployment',
|
||||
};
|
||||
|
||||
const statusIcon = {
|
||||
running: <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />,
|
||||
done: <CheckCircle2 className="h-3.5 w-3.5 text-[hsl(180,80%,25%)]" />,
|
||||
error: <XCircle className="h-3.5 w-3.5 text-destructive" />,
|
||||
warn: <AlertTriangle className="h-3.5 w-3.5 text-[hsl(37,90%,58%)]" />,
|
||||
skipped: <SkipForward className="h-3.5 w-3.5 text-muted-foreground" />,
|
||||
};
|
||||
|
||||
export function CoolifyDeployDialog({ projectPath, appName, onClose }) {
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [steps, setSteps] = useState([]);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setSteps([]);
|
||||
try {
|
||||
const res = await fetch('/api/coolify/upsert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectPath, appName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
setSteps(data.steps || []);
|
||||
if (data.success) {
|
||||
setResult(data);
|
||||
} else {
|
||||
setError(data.error || 'Upsert failed');
|
||||
setSteps(data.steps || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center">
|
||||
<div className="bg-card border rounded-lg shadow-lg w-[520px] max-h-[80vh] overflow-auto">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-sm font-semibold">Deploy to Coolify</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{projectPath}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Not started */}
|
||||
{!loading && !result && !error && steps.length === 0 && (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
This will read <code className="bg-secondary px-1 rounded text-xs">coolify.json</code>,
|
||||
create/update the Coolify app, set env vars, configure Traefik routing, and trigger a deploy.
|
||||
</p>
|
||||
<Button onClick={run}>Start Deploy</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steps */}
|
||||
{steps.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{steps.map((s) => (
|
||||
<div key={s.step} className="flex items-start gap-2">
|
||||
<span className="mt-0.5 shrink-0">{statusIcon[s.status] || <Circle className="h-3.5 w-3.5 text-muted-foreground" />}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium">{STEP_LABELS[s.step] || s.step}</p>
|
||||
{s.detail && (
|
||||
<p className="text-[11px] text-muted-foreground whitespace-pre-wrap break-all mt-0.5">{s.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading spinner */}
|
||||
{loading && steps.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Running upsert pipeline...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{result && (
|
||||
<div className="rounded-md bg-[hsl(180,80%,25%)]/10 border border-[hsl(180,80%,25%)]/30 p-3 text-xs">
|
||||
<p className="font-medium text-[hsl(180,80%,25%)]">Deployment triggered</p>
|
||||
<p className="text-muted-foreground mt-1">UUID: {result.uuid}</p>
|
||||
{result.domain && (
|
||||
<a href={`https://${result.domain}`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline mt-1 block">
|
||||
https://{result.domain}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/30 p-3 text-xs">
|
||||
<p className="font-medium text-destructive">Error</p>
|
||||
<p className="text-muted-foreground mt-1 whitespace-pre-wrap">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t flex justify-end gap-2">
|
||||
{error && !loading && (
|
||||
<Button variant="secondary" size="sm" onClick={run}>Retry</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
app/renderer/src/components/coolify/coolify-page.jsx
Normal file
131
app/renderer/src/components/coolify/coolify-page.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { Cloud, RefreshCw, Rocket, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { CoolifyAppCard } from './coolify-app-card';
|
||||
import { CoolifyDeployDialog } from './coolify-deploy-dialog';
|
||||
import { useCoolifyApps, useCoolifyDeploy, useCoolifyDelete, useCoolifyNextPort } from '@/hooks/use-coolify';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function CoolifyPage() {
|
||||
const { data: apps, isLoading, error, refetch } = useCoolifyApps();
|
||||
const { data: portInfo } = useCoolifyNextPort();
|
||||
const deployMut = useCoolifyDeploy();
|
||||
const deleteMut = useCoolifyDelete();
|
||||
|
||||
const [deployDialog, setDeployDialog] = useState(null); // { projectPath, appName }
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null); // { uuid, name }
|
||||
|
||||
const handleDeploy = (app) => {
|
||||
deployMut.mutate(app.uuid, {
|
||||
onSuccess: () => toast.success(`Deploy triggered for ${app.name}`),
|
||||
onError: (err) => toast.error(`Deploy failed: ${err.message}`),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (app) => {
|
||||
setDeleteConfirm({ uuid: app.uuid, name: app.name });
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteConfirm) return;
|
||||
deleteMut.mutate(deleteConfirm.uuid, {
|
||||
onSuccess: () => {
|
||||
toast.success(`${deleteConfirm.name} deleted`);
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: (err) => toast.error(`Delete failed: ${err.message}`),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-2">
|
||||
<Cloud className="h-6 w-6 text-primary" /> Coolify
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{apps ? `${apps.length} apps` : 'Loading...'}
|
||||
{portInfo && <span className="ml-2">Next port: {portInfo.nextPort}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="http://localhost:3100/reference"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
API Docs <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<Button variant="ghost" size="sm" onClick={() => refetch()} disabled={isLoading}>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/30 p-3 text-xs">
|
||||
<p className="font-medium text-destructive">Failed to load apps</p>
|
||||
<p className="text-muted-foreground mt-1">{error.message}</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Make sure the API server is running: <code className="bg-secondary px-1 rounded">npm run api</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{deleteConfirm && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/30 p-3 flex items-center gap-3">
|
||||
<p className="text-xs flex-1">
|
||||
Delete <strong>{deleteConfirm.name}</strong> from Coolify? This cannot be undone.
|
||||
</p>
|
||||
<Button variant="destructive" size="sm" className="text-xs" onClick={confirmDelete} disabled={deleteMut.isPending}>
|
||||
{deleteMut.isPending ? 'Deleting...' : 'Yes, delete'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={() => setDeleteConfirm(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apps grid */}
|
||||
{!error && apps && apps.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Cloud}
|
||||
title="No Coolify apps"
|
||||
description="Create an app through the API or upsert from a project's coolify.json"
|
||||
/>
|
||||
)}
|
||||
|
||||
{apps && apps.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{apps.map((app) => (
|
||||
<CoolifyAppCard
|
||||
key={app.uuid}
|
||||
app={app}
|
||||
onDeploy={handleDeploy}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deploy dialog */}
|
||||
{deployDialog && (
|
||||
<CoolifyDeployDialog
|
||||
projectPath={deployDialog.projectPath}
|
||||
appName={deployDialog.appName}
|
||||
onClose={() => {
|
||||
setDeployDialog(null);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { LayoutDashboard, Container, Settings } from 'lucide-react';
|
||||
import { LayoutDashboard, Container, Settings, Cloud } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useServers } from '@/hooks/use-servers';
|
||||
@@ -8,6 +8,7 @@ import { useAppContext } from '@/lib/app-context';
|
||||
const navItems = [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||
{ to: '/projects', icon: Container, label: 'Projects' },
|
||||
{ to: '/coolify', icon: Cloud, label: 'Coolify' },
|
||||
{ to: '/servers', icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
|
||||
54
app/renderer/src/hooks/use-coolify.js
Normal file
54
app/renderer/src/hooks/use-coolify.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { coolifyApi } from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
|
||||
export function useCoolifyApps() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.coolify.apps,
|
||||
queryFn: () => coolifyApi.listApps(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCoolifyServers() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.coolify.servers,
|
||||
queryFn: () => coolifyApi.listServers(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCoolifyNextPort() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.coolify.nextPort,
|
||||
queryFn: () => coolifyApi.getNextPort(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCoolifyDeploy() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (uuid) => coolifyApi.deploy(uuid),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.coolify.apps }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCoolifyUpsert() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ projectPath, appName }) => coolifyApi.upsert(projectPath, appName),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.coolify.apps });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.coolify.nextPort });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCoolifyDelete() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (uuid) => coolifyApi.deleteApp(uuid),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.coolify.apps }),
|
||||
});
|
||||
}
|
||||
@@ -36,3 +36,36 @@ export const configApi = {
|
||||
export const toolsApi = {
|
||||
openVSCodeDiff: (data) => api.openVSCodeDiff(data),
|
||||
};
|
||||
|
||||
// ─── Coolify API (HTTP fetch to Express server) ────────────────────
|
||||
|
||||
const COOLIFY_BASE = '/api/coolify';
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const coolifyApi = {
|
||||
listApps: () => fetchJson(`${COOLIFY_BASE}/apps`),
|
||||
findApp: (name) => fetchJson(`${COOLIFY_BASE}/apps/find/${encodeURIComponent(name)}`),
|
||||
createApp: (data) => fetchJson(`${COOLIFY_BASE}/apps`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateApp: (uuid, data) => fetchJson(`${COOLIFY_BASE}/apps/${uuid}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteApp: (uuid) => fetchJson(`${COOLIFY_BASE}/apps/${uuid}`, { method: 'DELETE' }),
|
||||
listEnvs: (uuid) => fetchJson(`${COOLIFY_BASE}/apps/${uuid}/envs`),
|
||||
setEnv: (uuid, key, value) => fetchJson(`${COOLIFY_BASE}/apps/${uuid}/envs`, { method: 'POST', body: JSON.stringify({ key, value }) }),
|
||||
deploy: (uuid) => fetchJson(`${COOLIFY_BASE}/apps/${uuid}/deploy`, { method: 'POST' }),
|
||||
listServers: () => fetchJson(`${COOLIFY_BASE}/servers`),
|
||||
getNextPort: () => fetchJson(`${COOLIFY_BASE}/next-port`),
|
||||
addRoute: (routeName, domain, port) => fetchJson(`${COOLIFY_BASE}/routes`, { method: 'POST', body: JSON.stringify({ routeName, domain, port }) }),
|
||||
checkDrift: (projectPath, appName) => fetchJson(`${COOLIFY_BASE}/drift`, { method: 'POST', body: JSON.stringify({ projectPath, appName }) }),
|
||||
upsert: (projectPath, appName) => fetchJson(`${COOLIFY_BASE}/upsert`, { method: 'POST', body: JSON.stringify({ projectPath, appName }) }),
|
||||
readConfig: (projectPath) => fetchJson(`${COOLIFY_BASE}/config?path=${encodeURIComponent(projectPath)}`),
|
||||
};
|
||||
|
||||
@@ -13,4 +13,10 @@ export const queryKeys = {
|
||||
config: {
|
||||
all: ['config'],
|
||||
},
|
||||
coolify: {
|
||||
apps: ['coolify', 'apps'],
|
||||
servers: ['coolify', 'servers'],
|
||||
nextPort: ['coolify', 'nextPort'],
|
||||
drift: (projectPath) => ['coolify', 'drift', projectPath],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,5 +19,8 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3100',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
13
package.json
13
package.json
@@ -6,6 +6,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"docker-deploy": "node cli/index.js",
|
||||
"api": "node api/server.js",
|
||||
"api:dev": "node --watch api/server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -18,12 +20,15 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^12.1.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"@scalar/express-api-reference": "^0.8.46",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"glob": "^11.0.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"inquirer": "^11.1.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"glob": "^11.0.0"
|
||||
"ssh2": "^1.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
Reference in New Issue
Block a user