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:
14
.env.example
14
.env.example
@@ -1,3 +1,14 @@
|
|||||||
|
# Backend Environment
|
||||||
|
PORT=4000
|
||||||
|
MONGO_URI=mongodb://mongo:27017/wisecoltci
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=supersecret
|
||||||
|
JWT_SECRET=change-me
|
||||||
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# Frontend Environment
|
||||||
|
VITE_API_URL=http://localhost:4000/api
|
||||||
|
|
||||||
# ---------------------------------- CLAUDE API SETTINGS ---------------------------------- #
|
# ---------------------------------- CLAUDE API SETTINGS ---------------------------------- #
|
||||||
# === Claude API Config ===
|
# === Claude API Config ===
|
||||||
API_KEY_LITE="your-lite-key"
|
API_KEY_LITE="your-lite-key"
|
||||||
@@ -7,6 +18,3 @@ ACTIVE_KEY=lite
|
|||||||
# === Anthropic API Settings ===
|
# === Anthropic API Settings ===
|
||||||
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
|
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
|
||||||
ANTHROPIC_MODEL="glm-4.7"
|
ANTHROPIC_MODEL="glm-4.7"
|
||||||
|
|
||||||
# Host üzerinde projelerin bulunduğu dizin (compose volume için, zorunludur)
|
|
||||||
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -8,7 +9,8 @@ export const config = {
|
|||||||
adminUsername: process.env.ADMIN_USERNAME || "admin",
|
adminUsername: process.env.ADMIN_USERNAME || "admin",
|
||||||
adminPassword: process.env.ADMIN_PASSWORD || "password",
|
adminPassword: process.env.ADMIN_PASSWORD || "password",
|
||||||
jwtSecret: process.env.JWT_SECRET || "changeme",
|
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) {
|
if (!config.jwtSecret) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import webhookRoutes from "./routes/webhooks.js";
|
|||||||
import { config } from "./config/env.js";
|
import { config } from "./config/env.js";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import { jobService } from "./services/jobService.js";
|
import { jobService } from "./services/jobService.js";
|
||||||
|
import { deploymentService } from "./services/deploymentService.js";
|
||||||
|
import { DeploymentProject } from "./models/deploymentProject.js";
|
||||||
import { Job } from "./models/job.js";
|
import { Job } from "./models/job.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -42,6 +44,7 @@ app.use("/api/settings", settingsRoutes);
|
|||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
const io = new Server(server, {
|
const io = new Server(server, {
|
||||||
|
path: "/api/socket.io",
|
||||||
cors: {
|
cors: {
|
||||||
origin: config.clientOrigin,
|
origin: config.clientOrigin,
|
||||||
methods: ["GET", "POST"]
|
methods: ["GET", "POST"]
|
||||||
@@ -49,6 +52,7 @@ const io = new Server(server, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
jobService.setSocket(io);
|
jobService.setSocket(io);
|
||||||
|
deploymentService.setSocket(io);
|
||||||
|
|
||||||
io.use((socket, next) => {
|
io.use((socket, next) => {
|
||||||
const token = socket.handshake.auth?.token as string | undefined;
|
const token = socket.handshake.auth?.token as string | undefined;
|
||||||
@@ -93,6 +97,29 @@ io.on("connection", (socket) => {
|
|||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
socket.leave(`job:${jobId}`);
|
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() {
|
async function start() {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { authMiddleware } from "../middleware/authMiddleware.js";
|
|||||||
import { deploymentService } from "../services/deploymentService.js";
|
import { deploymentService } from "../services/deploymentService.js";
|
||||||
import { DeploymentProject } from "../models/deploymentProject.js";
|
import { DeploymentProject } from "../models/deploymentProject.js";
|
||||||
import { DeploymentRun } from "../models/deploymentRun.js";
|
import { DeploymentRun } from "../models/deploymentRun.js";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -140,6 +141,9 @@ router.post("/", async (req, res) => {
|
|||||||
composeFile,
|
composeFile,
|
||||||
port
|
port
|
||||||
});
|
});
|
||||||
|
deploymentService
|
||||||
|
.runDeployment(created._id.toString(), { message: "First deployment" })
|
||||||
|
.catch(() => undefined);
|
||||||
return res.status(201).json(created);
|
return res.status(201).json(created);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message });
|
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 () => {
|
authMiddleware(req, res, async () => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
try {
|
try {
|
||||||
const deleted = await DeploymentProject.findByIdAndDelete(id);
|
const project = await DeploymentProject.findById(id);
|
||||||
if (!deleted) return res.status(404).json({ message: "Deployment bulunamadı" });
|
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
|
||||||
|
await deploymentService.cleanupProjectResources(project);
|
||||||
|
await DeploymentProject.findByIdAndDelete(id);
|
||||||
await DeploymentRun.deleteMany({ project: id });
|
await DeploymentRun.deleteMany({ project: id });
|
||||||
|
await fs.promises.rm(project.rootPath, { recursive: true, force: true });
|
||||||
return res.json({ success: true });
|
return res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message });
|
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 { id } = req.params;
|
||||||
const project = await DeploymentProject.findById(id);
|
const project = await DeploymentProject.findById(id);
|
||||||
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
|
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 });
|
return res.json({ queued: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
|
import { Server } from "socket.io";
|
||||||
|
import { config } from "../config/env.js";
|
||||||
import {
|
import {
|
||||||
DeploymentProject,
|
DeploymentProject,
|
||||||
DeploymentProjectDocument,
|
DeploymentProjectDocument,
|
||||||
@@ -13,7 +15,7 @@ import { Settings } from "../models/settings.js";
|
|||||||
|
|
||||||
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
|
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
|
||||||
|
|
||||||
const deploymentsRoot = "/workspace/deployments";
|
const deploymentsRoot = config.deploymentsRoot;
|
||||||
|
|
||||||
function slugify(value: string) {
|
function slugify(value: string) {
|
||||||
return value
|
return value
|
||||||
@@ -152,6 +154,39 @@ async function runCompose(project: DeploymentProjectDocument, onData: (line: str
|
|||||||
|
|
||||||
class DeploymentService {
|
class DeploymentService {
|
||||||
private running: Map<string, boolean> = new Map();
|
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) {
|
async listRemoteBranches(repoUrl: string) {
|
||||||
const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd());
|
const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd());
|
||||||
@@ -305,6 +340,7 @@ class DeploymentService {
|
|||||||
const runLogs: string[] = [];
|
const runLogs: string[] = [];
|
||||||
const pushLog = (line: string) => {
|
const pushLog = (line: string) => {
|
||||||
runLogs.push(line);
|
runLogs.push(line);
|
||||||
|
this.emitLog(projectId, line);
|
||||||
};
|
};
|
||||||
|
|
||||||
const runDoc = await DeploymentRun.create({
|
const runDoc = await DeploymentRun.create({
|
||||||
@@ -313,11 +349,16 @@ class DeploymentService {
|
|||||||
startedAt: new Date(),
|
startedAt: new Date(),
|
||||||
message: options?.message
|
message: options?.message
|
||||||
});
|
});
|
||||||
|
this.emitRun(projectId, runDoc);
|
||||||
|
|
||||||
await DeploymentProject.findByIdAndUpdate(projectId, {
|
await DeploymentProject.findByIdAndUpdate(projectId, {
|
||||||
lastStatus: "running",
|
lastStatus: "running",
|
||||||
lastMessage: options?.message || "Deploy başlıyor..."
|
lastMessage: options?.message || "Deploy başlıyor..."
|
||||||
});
|
});
|
||||||
|
await this.emitStatus(projectId, {
|
||||||
|
lastStatus: "running",
|
||||||
|
lastMessage: options?.message || "Deploy başlıyor..."
|
||||||
|
} as DeploymentProjectDocument);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureRepo(project, (line) => pushLog(line));
|
await ensureRepo(project, (line) => pushLog(line));
|
||||||
@@ -329,6 +370,11 @@ class DeploymentService {
|
|||||||
lastDeployAt: new Date(),
|
lastDeployAt: new Date(),
|
||||||
lastMessage: options?.message || "Başarılı"
|
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, {
|
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
||||||
status: "success",
|
status: "success",
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
@@ -336,6 +382,8 @@ class DeploymentService {
|
|||||||
logs: runLogs,
|
logs: runLogs,
|
||||||
message: options?.message
|
message: options?.message
|
||||||
});
|
});
|
||||||
|
const updatedRun = await DeploymentRun.findById(runDoc._id);
|
||||||
|
if (updatedRun) this.emitRun(projectId, updatedRun);
|
||||||
pushLog("Deploy tamamlandı: Başarılı");
|
pushLog("Deploy tamamlandı: Başarılı");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const duration = Date.now() - startedAt;
|
const duration = Date.now() - startedAt;
|
||||||
@@ -344,6 +392,11 @@ class DeploymentService {
|
|||||||
lastDeployAt: new Date(),
|
lastDeployAt: new Date(),
|
||||||
lastMessage: (err as Error).message
|
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, {
|
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
@@ -351,12 +404,26 @@ class DeploymentService {
|
|||||||
logs: runLogs,
|
logs: runLogs,
|
||||||
message: options?.message
|
message: options?.message
|
||||||
});
|
});
|
||||||
|
const updatedRun = await DeploymentRun.findById(runDoc._id);
|
||||||
|
if (updatedRun) this.emitRun(projectId, updatedRun);
|
||||||
pushLog(`Hata: ${(err as Error).message}`);
|
pushLog(`Hata: ${(err as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
this.running.delete(projectId);
|
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) {
|
async findByWebhookToken(token: string) {
|
||||||
return DeploymentProject.findOne({ webhookToken: token });
|
return DeploymentProject.findOne({ webhookToken: token });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
- ${DEPLOYMENTS_ROOT_HOST}:/workspace
|
- ${PWD}:${PWD}
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./.env
|
||||||
|
environment:
|
||||||
|
DEPLOYMENTS_ROOT: ${PWD}/deployments
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -29,7 +31,7 @@ services:
|
|||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
env_file:
|
env_file:
|
||||||
- ./frontend/.env
|
- ./.env
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ services:
|
|||||||
build: ./backend
|
build: ./backend
|
||||||
command: npm run build && npm start
|
command: npm run build && npm start
|
||||||
volumes:
|
volumes:
|
||||||
- ${DEPLOYMENTS_ROOT_HOST}:/workspace
|
- ${PWD}:${PWD}
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./.env
|
||||||
|
environment:
|
||||||
|
DEPLOYMENTS_ROOT: ${PWD}/deployments
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ services:
|
|||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
env_file:
|
env_file:
|
||||||
- ./frontend/.env
|
- ./.env
|
||||||
environment:
|
environment:
|
||||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS}
|
ALLOWED_HOSTS: ${ALLOWED_HOSTS}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
VITE_API_URL=http://localhost:4000/api
|
|
||||||
# Prod için izin verilecek host(lar), virgülle ayırabilirsiniz. Örn:
|
|
||||||
# ALLOWED_HOSTS=wisecolt-ci-frontend-ft2pzo-1c0eb3-188-245-185-248.traefik.me
|
|
||||||
@@ -7,6 +7,8 @@ import { Button } from "../components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||||
import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments";
|
import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments";
|
||||||
|
import { useDeploymentStream } from "../providers/live-provider";
|
||||||
|
import { useSocket } from "../providers/socket-provider";
|
||||||
|
|
||||||
export function DeploymentDetailPage() {
|
export function DeploymentDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -15,6 +17,8 @@ export function DeploymentDetailPage() {
|
|||||||
const [runs, setRuns] = useState<DeploymentRun[]>([]);
|
const [runs, setRuns] = useState<DeploymentRun[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [triggering, setTriggering] = useState(false);
|
const [triggering, setTriggering] = useState(false);
|
||||||
|
const stream = useDeploymentStream(id || "");
|
||||||
|
const socket = useSocket();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -27,12 +31,36 @@ export function DeploymentDetailPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket || !id) return;
|
||||||
|
socket.emit("deployment:subscribe", { deploymentId: id });
|
||||||
|
const handleRunUpdate = ({ deploymentId, run }: { deploymentId: string; run: DeploymentRun }) => {
|
||||||
|
if (deploymentId !== id) return;
|
||||||
|
setRuns((prev) => {
|
||||||
|
const existingIndex = prev.findIndex((item) => item._id === run._id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const next = [...prev];
|
||||||
|
next[existingIndex] = { ...next[existingIndex], ...run };
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [run, ...prev];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
socket.on("deployment:run", handleRunUpdate);
|
||||||
|
return () => {
|
||||||
|
socket.emit("deployment:unsubscribe", { deploymentId: id });
|
||||||
|
socket.off("deployment:run", handleRunUpdate);
|
||||||
|
};
|
||||||
|
}, [socket, id]);
|
||||||
|
|
||||||
const webhookUrl = useMemo(() => {
|
const webhookUrl = useMemo(() => {
|
||||||
if (!project) return "";
|
if (!project) return "";
|
||||||
return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`;
|
return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`;
|
||||||
}, [project]);
|
}, [project]);
|
||||||
|
|
||||||
const latestRun = runs[0];
|
const latestRun = runs[0];
|
||||||
|
const effectiveStatus = stream.status || project?.lastStatus || latestRun?.status || "idle";
|
||||||
|
const currentLogs = stream.logs.length > 0 ? stream.logs : latestRun?.logs || [];
|
||||||
|
|
||||||
const decorateLogLine = (line: string) => {
|
const decorateLogLine = (line: string) => {
|
||||||
const lower = line.toLowerCase();
|
const lower = line.toLowerCase();
|
||||||
@@ -56,7 +84,20 @@ export function DeploymentDetailPage() {
|
|||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
await navigator.clipboard.writeText(webhookUrl);
|
await navigator.clipboard.writeText(webhookUrl);
|
||||||
|
} else {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = webhookUrl;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
const ok = document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
if (!ok) throw new Error("copy failed");
|
||||||
|
}
|
||||||
toast.success("Webhook URL kopyalandı");
|
toast.success("Webhook URL kopyalandı");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Webhook URL kopyalanamadı");
|
toast.error("Webhook URL kopyalanamadı");
|
||||||
@@ -121,7 +162,7 @@ export function DeploymentDetailPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Genel Bilgiler</CardTitle>
|
<CardTitle>Genel Bilgiler</CardTitle>
|
||||||
<JobStatusBadge status={project.lastStatus} />
|
<JobStatusBadge status={effectiveStatus} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 text-sm text-muted-foreground">
|
<CardContent className="grid gap-4 text-sm text-muted-foreground">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
@@ -204,8 +245,8 @@ export function DeploymentDetailPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="max-h-72 overflow-auto rounded-md border border-border bg-black px-3 py-2 font-mono text-xs text-green-100">
|
<div className="max-h-72 overflow-auto rounded-md border border-border bg-black px-3 py-2 font-mono text-xs text-green-100">
|
||||||
{latestRun?.logs?.length ? (
|
{currentLogs.length ? (
|
||||||
latestRun.logs.map((line, idx) => (
|
currentLogs.map((line, idx) => (
|
||||||
<div key={idx} className="whitespace-pre-wrap">
|
<div key={idx} className="whitespace-pre-wrap">
|
||||||
{decorateLogLine(line)}
|
{decorateLogLine(line)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
updateDeployment
|
updateDeployment
|
||||||
} from "../api/deployments";
|
} from "../api/deployments";
|
||||||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||||
|
import { useLiveData } from "../providers/live-provider";
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
_id?: string;
|
_id?: string;
|
||||||
@@ -46,6 +47,7 @@ const defaultForm: FormState = {
|
|||||||
export function DeploymentsPage() {
|
export function DeploymentsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { deploymentStreams } = useLiveData();
|
||||||
const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "");
|
const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "");
|
||||||
const [deployments, setDeployments] = useState<DeploymentProject[]>([]);
|
const [deployments, setDeployments] = useState<DeploymentProject[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -84,6 +86,14 @@ export function DeploymentsPage() {
|
|||||||
setComposeOptions([]);
|
setComposeOptions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!form._id && !form.name) {
|
||||||
|
const normalized = repoUrl.replace(/\/+$/, "");
|
||||||
|
const lastPart = normalized.split("/").pop() || "";
|
||||||
|
const name = lastPart.replace(/\.git$/i, "");
|
||||||
|
if (name) {
|
||||||
|
setForm((prev) => ({ ...prev, name }));
|
||||||
|
}
|
||||||
|
}
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
setBranchLoading(true);
|
setBranchLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -292,7 +302,9 @@ export function DeploymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||||
<JobStatusBadge status={deployment.lastStatus} />
|
<JobStatusBadge
|
||||||
|
status={deploymentStreams[deployment._id]?.status || deployment.lastStatus}
|
||||||
|
/>
|
||||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
|
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
|
||||||
{deployment.env.toUpperCase()}
|
{deployment.env.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -23,7 +23,20 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
const handleCopy = async (value: string, label: string) => {
|
const handleCopy = async (value: string, label: string) => {
|
||||||
try {
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
|
} else {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = value;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
const ok = document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
if (!ok) throw new Error("copy failed");
|
||||||
|
}
|
||||||
toast.success(`${label} kopyalandı`);
|
toast.success(`${label} kopyalandı`);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`${label} kopyalanamadı`);
|
toast.error(`${label} kopyalanamadı`);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type JobStream = {
|
|||||||
|
|
||||||
type LiveContextValue = {
|
type LiveContextValue = {
|
||||||
jobStreams: Record<string, JobStream>;
|
jobStreams: Record<string, JobStream>;
|
||||||
|
deploymentStreams: Record<string, JobStream>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LiveContext = createContext<LiveContextValue | undefined>(undefined);
|
const LiveContext = createContext<LiveContextValue | undefined>(undefined);
|
||||||
@@ -19,6 +20,7 @@ const LiveContext = createContext<LiveContextValue | undefined>(undefined);
|
|||||||
export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({});
|
const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({});
|
||||||
|
const [deploymentStreams, setDeploymentStreams] = useState<Record<string, JobStream>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
@@ -54,20 +56,59 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeploymentLog = ({ deploymentId, line }: { deploymentId: string; line: string }) => {
|
||||||
|
if (!deploymentId) return;
|
||||||
|
setDeploymentStreams((prev) => {
|
||||||
|
const current = prev[deploymentId] || { logs: [] };
|
||||||
|
const nextLogs = [...current.logs, line].slice(-200);
|
||||||
|
return { ...prev, [deploymentId]: { ...current, logs: nextLogs } };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeploymentStatus = ({
|
||||||
|
deploymentId,
|
||||||
|
status,
|
||||||
|
lastRunAt,
|
||||||
|
lastMessage,
|
||||||
|
runCount,
|
||||||
|
lastDurationMs
|
||||||
|
}: {
|
||||||
|
deploymentId: string;
|
||||||
|
status?: string;
|
||||||
|
lastRunAt?: string;
|
||||||
|
lastMessage?: string;
|
||||||
|
runCount?: number;
|
||||||
|
lastDurationMs?: number;
|
||||||
|
}) => {
|
||||||
|
if (!deploymentId) return;
|
||||||
|
setDeploymentStreams((prev) => {
|
||||||
|
const current = prev[deploymentId] || { logs: [] };
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[deploymentId]: { ...current, status, lastRunAt, lastMessage, runCount, lastDurationMs }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
socket.on("job:log", handleJobLog);
|
socket.on("job:log", handleJobLog);
|
||||||
socket.on("job:status", handleJobStatus);
|
socket.on("job:status", handleJobStatus);
|
||||||
|
socket.on("deployment:log", handleDeploymentLog);
|
||||||
|
socket.on("deployment:status", handleDeploymentStatus);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("job:log", handleJobLog);
|
socket.off("job:log", handleJobLog);
|
||||||
socket.off("job:status", handleJobStatus);
|
socket.off("job:status", handleJobStatus);
|
||||||
|
socket.off("deployment:log", handleDeploymentLog);
|
||||||
|
socket.off("deployment:status", handleDeploymentStatus);
|
||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
jobStreams
|
jobStreams,
|
||||||
|
deploymentStreams
|
||||||
}),
|
}),
|
||||||
[jobStreams]
|
[jobStreams, deploymentStreams]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
|
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
|
||||||
@@ -87,3 +128,12 @@ export function useJobStream(jobId: string) {
|
|||||||
[ctx.jobStreams, jobId]
|
[ctx.jobStreams, jobId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDeploymentStream(deploymentId: string) {
|
||||||
|
const ctx = useContext(LiveContext);
|
||||||
|
if (!ctx) throw new Error("useDeploymentStream LiveProvider içinde kullanılmalı");
|
||||||
|
return useMemo(
|
||||||
|
() => ctx.deploymentStreams[deploymentId] || { logs: [], status: "idle", runCount: 0 },
|
||||||
|
[ctx.deploymentStreams, deploymentId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
|||||||
const socketRef = useRef<Socket | null>(null);
|
const socketRef = useRef<Socket | null>(null);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
const baseUrl = useMemo(() => apiClient.defaults.baseURL || window.location.origin, []);
|
const baseUrl = useMemo(() => window.location.origin, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -22,6 +22,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
|||||||
|
|
||||||
const socket = io(baseUrl, {
|
const socket = io(baseUrl, {
|
||||||
auth: { token },
|
auth: { token },
|
||||||
|
path: "/api/socket.io",
|
||||||
transports: ["websocket", "polling"]
|
transports: ["websocket", "polling"]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user