Add Merge Dev → Main button for all projects

- New SSE endpoint /api/merge/ for git-only merge workflow
- Merge button shown on all projects (disabled when synced)
- Deploy button only for projects with app containers
- Refactored SSE step runner to shared function

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ondrej Trochta
2026-03-07 23:44:25 +01:00
parent 8af5a27b4f
commit e3e82cafc0
3 changed files with 54 additions and 18 deletions
+29 -9
View File
@@ -56,22 +56,22 @@ function runStep(step: Step): Promise<{ exitCode: number; output: string }> {
});
}
export async function handleDeploy(req: http.IncomingMessage, res: http.ServerResponse, projectKey: string) {
const project = PROJECTS[projectKey];
if (!project) {
res.writeHead(404);
res.end("Unknown project");
return;
}
function getMergeSteps(project: ProjectConfig): Step[] {
return [
{ name: "git fetch", cmd: "git fetch --all", cwd: project.path },
{ name: "git checkout main", cmd: "git checkout main", cwd: project.path },
{ name: "git merge dev", cmd: "git merge dev --no-edit", cwd: project.path },
{ name: "git push", cmd: "git push origin main", cwd: project.path },
];
}
async function runSSESteps(res: http.ServerResponse, project: ProjectConfig, steps: Step[]) {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
const steps = getDeploySteps(project);
sendSSE(res, "init", {
project: project.name,
steps: steps.map((s) => s.name),
@@ -100,3 +100,23 @@ export async function handleDeploy(req: http.IncomingMessage, res: http.ServerRe
res.end();
}
export async function handleMerge(req: http.IncomingMessage, res: http.ServerResponse, projectKey: string) {
const project = PROJECTS[projectKey];
if (!project) {
res.writeHead(404);
res.end("Unknown project");
return;
}
await runSSESteps(res, project, getMergeSteps(project));
}
export async function handleDeploy(req: http.IncomingMessage, res: http.ServerResponse, projectKey: string) {
const project = PROJECTS[projectKey];
if (!project) {
res.writeHead(404);
res.end("Unknown project");
return;
}
await runSSESteps(res, project, getDeploySteps(project));
}
+4 -1
View File
@@ -2,7 +2,7 @@
import http from "node:http";
import { getStatusAll, getHealthAll, actionProject } from "./api.js";
import { handleDeploy } from "./deploy.js";
import { handleDeploy, handleMerge } from "./deploy.js";
import { getIndexHtml } from "./web-ui.js";
const PORT = 5080;
@@ -36,6 +36,9 @@ const server = http.createServer(async (req, res) => {
} else if (path.startsWith("/api/deploy/") && req.method === "GET") {
const projectKey = path.replace("/api/deploy/", "");
await handleDeploy(req, res, projectKey);
} else if (path.startsWith("/api/merge/") && req.method === "GET") {
const projectKey = path.replace("/api/merge/", "");
await handleMerge(req, res, projectKey);
} else {
res.writeHead(404);
res.end("Not found");
+21 -8
View File
@@ -51,6 +51,8 @@ export function getIndexHtml(): string {
.btn:disabled { opacity:0.4; cursor:not-allowed; }
.btn-deploy { border-color:var(--orange); color:var(--orange); }
.btn-deploy:hover { background:rgba(249,115,22,0.12); }
.btn-merge { border-color:var(--yellow); color:var(--yellow); }
.btn-merge:hover { background:rgba(234,179,8,0.12); }
.btn-up { border-color:var(--green); color:var(--green); }
.btn-down { border-color:var(--red); color:var(--red); }
@@ -153,13 +155,15 @@ function render() {
return '<div class="svc"><span class="dot '+dot+'"></span>'+esc(s.label)+' '+link+'</div>';
}).join('');
// deploy button
// action buttons
const isRunning = ds?.running;
const canDeploy = p.services.some(s => s.isApp);
const deployBtn = canDeploy
? '<button class="btn btn-deploy" onclick="deploy(\\''+p.key+'\\')" '+(isRunning?'disabled':'')+'>'+
(isRunning ? '<span class="spinning">&#9881;</span> Deploying...' : '&#9654; Deploy') + '</button>'
(isRunning && ds?.action==='deploy' ? '<span class="spinning">&#9881;</span> Deploying...' : '&#9654; Deploy') + '</button>'
: '';
const mergeBtn = '<button class="btn btn-merge" onclick="merge(\\''+p.key+'\\')" '+(isRunning || p.devAheadOfMain===0?'disabled':'')+'>'+
(isRunning && ds?.action==='merge' ? '<span class="spinning">&#9881;</span> Merging...' : '&#9654; Merge Dev &#8594; Main') + '</button>';
// deploy panel
let panelHtml = '';
@@ -183,6 +187,7 @@ function render() {
aheadHtml+
'</div>'+
'<div class="card-actions">'+
mergeBtn+
deployBtn+
'<button class="btn btn-up" onclick="act(\\''+p.key+'\\',\\'up\\')" '+(isRunning?'disabled':'')+'>Start</button>'+
'<button class="btn btn-down" onclick="act(\\''+p.key+'\\',\\'down\\')" '+(isRunning?'disabled':'')+'>Stop</button>'+
@@ -194,13 +199,13 @@ function render() {
}).join('');
}
function deploy(key) {
if (!confirm('Deploy ' + key + '? (merge dev→main, build, restart)')) return;
function runSSE(key, endpoint, actionName, confirmMsg) {
if (!confirm(confirmMsg)) return;
deployState[key] = { steps:[], stepStatuses:[], outputs:[], running:true, error:false };
deployState[key] = { steps:[], stepStatuses:[], outputs:[], running:true, error:false, action:actionName };
render();
const es = new EventSource('/api/deploy/' + key);
const es = new EventSource(endpoint);
es.addEventListener('init', (e) => {
const d = JSON.parse(e.data);
@@ -223,9 +228,9 @@ function deploy(key) {
deployState[key].running = false;
if (!d.success) {
deployState[key].error = true;
toast(d.error || 'Deploy failed', 'fail');
toast(d.error || actionName + ' failed', 'fail');
} else {
toast(key + ' deployed!', 'ok');
toast(key + ' ' + actionName + ' done!', 'ok');
setTimeout(refresh, 1500);
}
render();
@@ -240,6 +245,14 @@ function deploy(key) {
};
}
function deploy(key) {
runSSE(key, '/api/deploy/' + key, 'deploy', 'Deploy ' + key + '? (merge dev\\u2192main, build, restart)');
}
function merge(key) {
runSSE(key, '/api/merge/' + key, 'merge', 'Merge dev \\u2192 main for ' + key + '?');
}
async function act(key, action) {
try {
const r = await fetch('/api/action', {