diff --git a/.env.example b/.env.example index a895eef..94d6c1e 100644 --- a/.env.example +++ b/.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 Config === API_KEY_LITE="your-lite-key" @@ -7,6 +18,3 @@ ACTIVE_KEY=lite # === Anthropic API Settings === ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic" ANTHROPIC_MODEL="glm-4.7" - -# Host üzerinde projelerin bulunduğu dizin (compose volume için, zorunludur) -DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 502f5a8..0000000 --- a/backend/.env.example +++ /dev/null @@ -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 diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 3d38a81..55124d8 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -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) { diff --git a/backend/src/index.ts b/backend/src/index.ts index 0a83222..1abc08a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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() { diff --git a/backend/src/routes/deployments.ts b/backend/src/routes/deployments.ts index d2b14bc..dbcf724 100644 --- a/backend/src/routes/deployments.ts +++ b/backend/src/routes/deployments.ts @@ -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 }); }); }); diff --git a/backend/src/services/deploymentService.ts b/backend/src/services/deploymentService.ts index b49c436..e9b2591 100644 --- a/backend/src/services/deploymentService.ts +++ b/backend/src/services/deploymentService.ts @@ -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 = new Map(); + private io: Server | null = null; + + setSocket(io: Server) { + this.io = io; + } + + private async emitStatus(deploymentId: string, payload: Partial) { + 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 }); } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1b8d5df..301794c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,10 +13,12 @@ services: volumes: - ./backend:/app - /app/node_modules - - ${DEPLOYMENTS_ROOT_HOST}:/workspace + - ${PWD}:${PWD} - /var/run/docker.sock:/var/run/docker.sock env_file: - - ./backend/.env + - ./.env + environment: + DEPLOYMENTS_ROOT: ${PWD}/deployments ports: - "4000:4000" depends_on: @@ -29,7 +31,7 @@ services: - ./frontend:/app - /app/node_modules env_file: - - ./frontend/.env + - ./.env ports: - "5173:5173" depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index aa40d6d..59e2ea7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,10 +3,12 @@ services: build: ./backend command: npm run build && npm start volumes: - - ${DEPLOYMENTS_ROOT_HOST}:/workspace + - ${PWD}:${PWD} - /var/run/docker.sock:/var/run/docker.sock env_file: - - ./backend/.env + - ./.env + environment: + DEPLOYMENTS_ROOT: ${PWD}/deployments ports: - "4000:4000" @@ -17,7 +19,7 @@ services: - ./frontend:/app - /app/node_modules env_file: - - ./frontend/.env + - ./.env environment: ALLOWED_HOSTS: ${ALLOWED_HOSTS} ports: diff --git a/frontend/.env.example b/frontend/.env.example deleted file mode 100644 index 0d2d79c..0000000 --- a/frontend/.env.example +++ /dev/null @@ -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 diff --git a/frontend/src/pages/DeploymentDetailPage.tsx b/frontend/src/pages/DeploymentDetailPage.tsx index fb81374..deedbdd 100644 --- a/frontend/src/pages/DeploymentDetailPage.tsx +++ b/frontend/src/pages/DeploymentDetailPage.tsx @@ -7,6 +7,8 @@ import { Button } from "../components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; import { JobStatusBadge } from "../components/JobStatusBadge"; import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments"; +import { useDeploymentStream } from "../providers/live-provider"; +import { useSocket } from "../providers/socket-provider"; export function DeploymentDetailPage() { const { id } = useParams<{ id: string }>(); @@ -15,6 +17,8 @@ export function DeploymentDetailPage() { const [runs, setRuns] = useState([]); const [loading, setLoading] = useState(true); const [triggering, setTriggering] = useState(false); + const stream = useDeploymentStream(id || ""); + const socket = useSocket(); useEffect(() => { if (!id) return; @@ -27,12 +31,36 @@ export function DeploymentDetailPage() { .finally(() => setLoading(false)); }, [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(() => { if (!project) return ""; return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`; }, [project]); 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 lower = line.toLowerCase(); @@ -56,7 +84,20 @@ export function DeploymentDetailPage() { const handleCopy = async () => { try { - await navigator.clipboard.writeText(webhookUrl); + if (navigator.clipboard && window.isSecureContext) { + 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ı"); } catch { toast.error("Webhook URL kopyalanamadı"); @@ -121,7 +162,7 @@ export function DeploymentDetailPage() { Genel Bilgiler - +
@@ -204,8 +245,8 @@ export function DeploymentDetailPage() {
- {latestRun?.logs?.length ? ( - latestRun.logs.map((line, idx) => ( + {currentLogs.length ? ( + currentLogs.map((line, idx) => (
{decorateLogLine(line)}
diff --git a/frontend/src/pages/DeploymentsPage.tsx b/frontend/src/pages/DeploymentsPage.tsx index f85af50..1f6413c 100644 --- a/frontend/src/pages/DeploymentsPage.tsx +++ b/frontend/src/pages/DeploymentsPage.tsx @@ -25,6 +25,7 @@ import { updateDeployment } from "../api/deployments"; import { JobStatusBadge } from "../components/JobStatusBadge"; +import { useLiveData } from "../providers/live-provider"; type FormState = { _id?: string; @@ -46,6 +47,7 @@ const defaultForm: FormState = { export function DeploymentsPage() { const navigate = useNavigate(); const location = useLocation(); + const { deploymentStreams } = useLiveData(); const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, ""); const [deployments, setDeployments] = useState([]); const [loading, setLoading] = useState(false); @@ -84,6 +86,14 @@ export function DeploymentsPage() { setComposeOptions([]); 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 () => { setBranchLoading(true); try { @@ -292,7 +302,9 @@ export function DeploymentsPage() {
- + {deployment.env.toUpperCase()} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index f7d2111..d4baba6 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -23,7 +23,20 @@ export function SettingsPage() { const handleCopy = async (value: string, label: string) => { try { - await navigator.clipboard.writeText(value); + if (navigator.clipboard && window.isSecureContext) { + 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ı`); } catch { toast.error(`${label} kopyalanamadı`); diff --git a/frontend/src/providers/live-provider.tsx b/frontend/src/providers/live-provider.tsx index 1b78017..6d5af6f 100644 --- a/frontend/src/providers/live-provider.tsx +++ b/frontend/src/providers/live-provider.tsx @@ -12,6 +12,7 @@ type JobStream = { type LiveContextValue = { jobStreams: Record; + deploymentStreams: Record; }; const LiveContext = createContext(undefined); @@ -19,6 +20,7 @@ const LiveContext = createContext(undefined); export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const socket = useSocket(); const [jobStreams, setJobStreams] = useState>({}); + const [deploymentStreams, setDeploymentStreams] = useState>({}); useEffect(() => { 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:status", handleJobStatus); + socket.on("deployment:log", handleDeploymentLog); + socket.on("deployment:status", handleDeploymentStatus); return () => { socket.off("job:log", handleJobLog); socket.off("job:status", handleJobStatus); + socket.off("deployment:log", handleDeploymentLog); + socket.off("deployment:status", handleDeploymentStatus); }; }, [socket]); const value = useMemo( () => ({ - jobStreams + jobStreams, + deploymentStreams }), - [jobStreams] + [jobStreams, deploymentStreams] ); return {children}; @@ -87,3 +128,12 @@ export function useJobStream(jobId: string) { [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] + ); +} diff --git a/frontend/src/providers/socket-provider.tsx b/frontend/src/providers/socket-provider.tsx index f7abafd..81ef037 100644 --- a/frontend/src/providers/socket-provider.tsx +++ b/frontend/src/providers/socket-provider.tsx @@ -10,7 +10,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr const socketRef = useRef(null); const [ready, setReady] = useState(false); - const baseUrl = useMemo(() => apiClient.defaults.baseURL || window.location.origin, []); + const baseUrl = useMemo(() => window.location.origin, []); useEffect(() => { if (!token) { @@ -22,6 +22,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr const socket = io(baseUrl, { auth: { token }, + path: "/api/socket.io", transports: ["websocket", "polling"] });