feat(deployments): anlık durum ve log izleme özelliği ekle

- Socket.IO tabanlı gerçek zamanlı deployment log ve durum bildirimleri ekle
- deployment:subscribe ve deployment:unsubscribe soket olaylarını destekle
- DeploymentService'e anlık durum ve log yayınlama özelliği ekle
- Deployment silinirken docker kaynaklarını temizle
- Ortam değişkenlerini tek bir .env.example dosyasında birleştir
- Docker compose yapılandırmasını güncelle (PWD ve DEPLOYMENTS_ROOT kullan)
- Repo URL'sinden proje adını otomatik öner
- Güvensiz bağlamlar için clipboard kopya fallback mekanizması ekle
- Socket.IO path'ini /api/socket.io olarak ayarla
This commit is contained in:
2026-01-19 15:11:45 +03:00
parent a87baa653a
commit e7a5690d98
14 changed files with 257 additions and 35 deletions

View File

@@ -1,9 +0,0 @@
PORT=4000
# Prod için zorunlu Mongo bağlantısı
# Örnek: mongodb://mongo:27017/wisecoltci
MONGO_URI=mongodb://mongo:27017/wisecoltci
ADMIN_USERNAME=admin
ADMIN_PASSWORD=supersecret
JWT_SECRET=change-me
CLIENT_ORIGIN=http://localhost:5173
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace

View File

@@ -1,4 +1,5 @@
import dotenv from "dotenv";
import path from "path";
dotenv.config();
@@ -8,7 +9,8 @@ export const config = {
adminUsername: process.env.ADMIN_USERNAME || "admin",
adminPassword: process.env.ADMIN_PASSWORD || "password",
jwtSecret: process.env.JWT_SECRET || "changeme",
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173"
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173",
deploymentsRoot: process.env.DEPLOYMENTS_ROOT || path.join(process.cwd(), "deployments")
};
if (!config.jwtSecret) {

View File

@@ -11,6 +11,8 @@ import webhookRoutes from "./routes/webhooks.js";
import { config } from "./config/env.js";
import jwt from "jsonwebtoken";
import { jobService } from "./services/jobService.js";
import { deploymentService } from "./services/deploymentService.js";
import { DeploymentProject } from "./models/deploymentProject.js";
import { Job } from "./models/job.js";
const app = express();
@@ -42,6 +44,7 @@ app.use("/api/settings", settingsRoutes);
const server = http.createServer(app);
const io = new Server(server, {
path: "/api/socket.io",
cors: {
origin: config.clientOrigin,
methods: ["GET", "POST"]
@@ -49,6 +52,7 @@ const io = new Server(server, {
});
jobService.setSocket(io);
deploymentService.setSocket(io);
io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined;
@@ -93,6 +97,29 @@ io.on("connection", (socket) => {
if (!jobId) return;
socket.leave(`job:${jobId}`);
});
socket.on("deployment:subscribe", async ({ deploymentId }: { deploymentId: string }) => {
if (!deploymentId) return;
socket.join(`deployment:${deploymentId}`);
try {
const deployment = await DeploymentProject.findById(deploymentId);
if (deployment) {
socket.emit("deployment:status", {
deploymentId,
status: deployment.lastStatus,
lastRunAt: deployment.lastDeployAt,
lastMessage: deployment.lastMessage
});
}
} catch {
// sessizce geç
}
});
socket.on("deployment:unsubscribe", ({ deploymentId }: { deploymentId: string }) => {
if (!deploymentId) return;
socket.leave(`deployment:${deploymentId}`);
});
});
async function start() {

View File

@@ -5,6 +5,7 @@ import { authMiddleware } from "../middleware/authMiddleware.js";
import { deploymentService } from "../services/deploymentService.js";
import { DeploymentProject } from "../models/deploymentProject.js";
import { DeploymentRun } from "../models/deploymentRun.js";
import fs from "fs";
const router = Router();
@@ -140,6 +141,9 @@ router.post("/", async (req, res) => {
composeFile,
port
});
deploymentService
.runDeployment(created._id.toString(), { message: "First deployment" })
.catch(() => undefined);
return res.status(201).json(created);
} catch (err) {
return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message });
@@ -174,9 +178,12 @@ router.delete("/:id", async (req, res) => {
authMiddleware(req, res, async () => {
const { id } = req.params;
try {
const deleted = await DeploymentProject.findByIdAndDelete(id);
if (!deleted) return res.status(404).json({ message: "Deployment bulunamadı" });
const project = await DeploymentProject.findById(id);
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
await deploymentService.cleanupProjectResources(project);
await DeploymentProject.findByIdAndDelete(id);
await DeploymentRun.deleteMany({ project: id });
await fs.promises.rm(project.rootPath, { recursive: true, force: true });
return res.json({ success: true });
} catch (err) {
return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message });
@@ -189,7 +196,9 @@ router.post("/:id/run", async (req, res) => {
const { id } = req.params;
const project = await DeploymentProject.findById(id);
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
deploymentService.runDeployment(id).catch(() => undefined);
deploymentService
.runDeployment(id, { message: "Elle deploy tetikleme" })
.catch(() => undefined);
return res.json({ queued: true });
});
});

View File

@@ -2,6 +2,8 @@ import fs from "fs";
import path from "path";
import crypto from "crypto";
import { spawn } from "child_process";
import { Server } from "socket.io";
import { config } from "../config/env.js";
import {
DeploymentProject,
DeploymentProjectDocument,
@@ -13,7 +15,7 @@ import { Settings } from "../models/settings.js";
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
const deploymentsRoot = "/workspace/deployments";
const deploymentsRoot = config.deploymentsRoot;
function slugify(value: string) {
return value
@@ -152,6 +154,39 @@ async function runCompose(project: DeploymentProjectDocument, onData: (line: str
class DeploymentService {
private running: Map<string, boolean> = new Map();
private io: Server | null = null;
setSocket(io: Server) {
this.io = io;
}
private async emitStatus(deploymentId: string, payload: Partial<DeploymentProjectDocument>) {
if (!this.io) return;
const runCount = await DeploymentRun.countDocuments({ project: deploymentId });
const body = {
deploymentId,
status: payload.lastStatus,
lastRunAt: payload.lastDeployAt,
lastMessage: payload.lastMessage,
runCount
};
this.io.to(`deployment:${deploymentId}`).emit("deployment:status", body);
this.io.emit("deployment:status", body);
}
private emitLog(deploymentId: string, line: string) {
if (!this.io) return;
this.io.to(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
this.io.except(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
}
private emitRun(deploymentId: string, run: DeploymentRun) {
if (!this.io) return;
this.io.to(`deployment:${deploymentId}`).emit("deployment:run", {
deploymentId,
run
});
}
async listRemoteBranches(repoUrl: string) {
const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd());
@@ -305,6 +340,7 @@ class DeploymentService {
const runLogs: string[] = [];
const pushLog = (line: string) => {
runLogs.push(line);
this.emitLog(projectId, line);
};
const runDoc = await DeploymentRun.create({
@@ -313,11 +349,16 @@ class DeploymentService {
startedAt: new Date(),
message: options?.message
});
this.emitRun(projectId, runDoc);
await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "running",
lastMessage: options?.message || "Deploy başlıyor..."
});
await this.emitStatus(projectId, {
lastStatus: "running",
lastMessage: options?.message || "Deploy başlıyor..."
} as DeploymentProjectDocument);
try {
await ensureRepo(project, (line) => pushLog(line));
@@ -329,6 +370,11 @@ class DeploymentService {
lastDeployAt: new Date(),
lastMessage: options?.message || "Başarılı"
});
await this.emitStatus(projectId, {
lastStatus: "success",
lastDeployAt: new Date(),
lastMessage: options?.message || "Başarılı"
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "success",
finishedAt: new Date(),
@@ -336,6 +382,8 @@ class DeploymentService {
logs: runLogs,
message: options?.message
});
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
pushLog("Deploy tamamlandı: Başarılı");
} catch (err) {
const duration = Date.now() - startedAt;
@@ -344,6 +392,11 @@ class DeploymentService {
lastDeployAt: new Date(),
lastMessage: (err as Error).message
});
await this.emitStatus(projectId, {
lastStatus: "failed",
lastDeployAt: new Date(),
lastMessage: (err as Error).message
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "failed",
finishedAt: new Date(),
@@ -351,12 +404,26 @@ class DeploymentService {
logs: runLogs,
message: options?.message
});
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
pushLog(`Hata: ${(err as Error).message}`);
} finally {
this.running.delete(projectId);
}
}
async cleanupProjectResources(project: DeploymentProjectDocument) {
const composePath = path.join(project.rootPath, project.composeFile);
if (!fs.existsSync(composePath)) {
return;
}
await runCommand(
`docker compose -f ${project.composeFile} down --remove-orphans -v --rmi local`,
project.rootPath,
() => undefined
);
}
async findByWebhookToken(token: string) {
return DeploymentProject.findOne({ webhookToken: token });
}