From e5fd3bd9d58c84e7ea8e40fe0bf8dad13e7da234 Mon Sep 17 00:00:00 2001 From: wisecolt Date: Sun, 18 Jan 2026 16:24:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(deployments):=20docker=20tabanl=C4=B1=20pr?= =?UTF-8?q?oje=20y=C3=B6netim=20ve=20otomatik=20deploy=20sistemi=20ekle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker Compose projeleri için tam kapsamlı yönetim paneli ve otomatik deployment altyapısı eklendi. Sistem özellikleri: - Belirtilen root dizin altındaki docker-compose dosyası içeren projeleri tarama - Git repo bağlantısı ile branch yönetimi ve klonlama/pull işlemleri - Docker compose up/down komutları ile otomatik deploy - Gitea webhook entegrasyonu ile commit bazlı tetikleme - Deploy geçmişi, log kayıtları ve durum takibi (running/success/failed) - Deploy metrikleri ve dashboard görselleştirmesi - Webhook token ve secret yönetimi ile güvenlik - Proje favicon servisi Teknik değişiklikler: - Backend: deploymentProject, deploymentRun ve settings modelleri eklendi - Backend: deploymentService ile git ve docker işlemleri otomatize edildi - Backend: webhook doğrulaması için signature kontrolü eklendi - Docker: docker-cli ve docker-compose bağımlılıkları eklendi - Frontend: deployments ve settings sayfaları eklendi - Frontend: dashboard'a deploy metrikleri ve aktivite akışı eklendi - API: /api/deployments ve /api/settings yolları eklendi --- .env.example | 12 + backend/.env.example | 2 +- backend/Dockerfile | 2 +- backend/src/config/env.ts | 3 +- backend/src/index.ts | 14 +- backend/src/models/deploymentProject.ts | 49 ++ backend/src/models/deploymentRun.ts | 34 ++ backend/src/models/settings.ts | 18 + backend/src/routes/deployments.ts | 193 +++++++ backend/src/routes/settings.ts | 34 ++ backend/src/routes/webhooks.ts | 66 +++ backend/src/services/deploymentService.ts | 366 ++++++++++++++ docker-compose.dev.yml | 2 + docker-compose.yml | 3 + frontend/.env.example | 2 +- frontend/src/App.tsx | 6 + frontend/src/api/deployments.ts | 115 +++++ frontend/src/api/settings.ts | 22 + frontend/src/components/DashboardLayout.tsx | 13 +- frontend/src/pages/DeploymentDetailPage.tsx | 221 ++++++++ frontend/src/pages/DeploymentsPage.tsx | 532 ++++++++++++++++++++ frontend/src/pages/HomePage.tsx | 164 +++++- frontend/src/pages/SettingsPage.tsx | 165 ++++++ 23 files changed, 2005 insertions(+), 33 deletions(-) create mode 100644 .env.example create mode 100644 backend/src/models/deploymentProject.ts create mode 100644 backend/src/models/deploymentRun.ts create mode 100644 backend/src/models/settings.ts create mode 100644 backend/src/routes/deployments.ts create mode 100644 backend/src/routes/settings.ts create mode 100644 backend/src/routes/webhooks.ts create mode 100644 backend/src/services/deploymentService.ts create mode 100644 frontend/src/api/deployments.ts create mode 100644 frontend/src/api/settings.ts create mode 100644 frontend/src/pages/DeploymentDetailPage.tsx create mode 100644 frontend/src/pages/DeploymentsPage.tsx create mode 100644 frontend/src/pages/SettingsPage.tsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a895eef --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# ---------------------------------- CLAUDE API SETTINGS ---------------------------------- # +# === Claude API Config === +API_KEY_LITE="your-lite-key" +API_KEY_PRO="your-pro-key" +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 index c263a30..ac7b8e5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,8 +2,8 @@ PORT=4000 # Prod için zorunlu Mongo bağlantısı # Örnek: mongodb://:@:27017/wisecoltci?authSource=wisecoltci MONGO_URI=mongodb://app:change-me@mongo-host:27017/wisecoltci?authSource=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/Dockerfile b/backend/Dockerfile index 6e58f00..1c488f2 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,7 +3,7 @@ FROM node:20-alpine WORKDIR /app COPY package*.json . -RUN apk add --no-cache git openssh-client && npm install +RUN apk add --no-cache git openssh-client docker-cli docker-cli-compose && npm install COPY tsconfig.json . COPY src ./src diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 3d38a81..fdd0eb9 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -8,7 +8,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: "/workspace" }; if (!config.jwtSecret) { diff --git a/backend/src/index.ts b/backend/src/index.ts index f9c60e8..0a83222 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5,6 +5,9 @@ import mongoose from "mongoose"; import { Server } from "socket.io"; import authRoutes from "./routes/auth.js"; import jobsRoutes from "./routes/jobs.js"; +import deploymentsRoutes from "./routes/deployments.js"; +import settingsRoutes from "./routes/settings.js"; +import webhookRoutes from "./routes/webhooks.js"; import { config } from "./config/env.js"; import jwt from "jsonwebtoken"; import { jobService } from "./services/jobService.js"; @@ -18,7 +21,13 @@ app.use( credentials: true }) ); -app.use(express.json()); +app.use( + express.json({ + verify: (req, _res, buf) => { + (req as { rawBody?: Buffer }).rawBody = buf; + } + }) +); app.get("/health", (_req, res) => { res.json({ status: "ok" }); @@ -26,6 +35,9 @@ app.get("/health", (_req, res) => { app.use("/api/auth", authRoutes); app.use("/api/jobs", jobsRoutes); +app.use("/", webhookRoutes); +app.use("/api/deployments", deploymentsRoutes); +app.use("/api/settings", settingsRoutes); const server = http.createServer(app); diff --git a/backend/src/models/deploymentProject.ts b/backend/src/models/deploymentProject.ts new file mode 100644 index 0000000..0538cff --- /dev/null +++ b/backend/src/models/deploymentProject.ts @@ -0,0 +1,49 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export type ComposeFile = "docker-compose.yml" | "docker-compose.dev.yml"; +export type DeploymentStatus = "idle" | "running" | "success" | "failed"; +export type DeploymentEnv = "dev" | "prod"; + +export interface DeploymentProjectDocument extends Document { + name: string; + rootPath: string; + repoUrl: string; + branch: string; + composeFile: ComposeFile; + webhookToken: string; + env: DeploymentEnv; + port?: number; + lastDeployAt?: Date; + lastStatus: DeploymentStatus; + lastMessage?: string; + createdAt: Date; + updatedAt: Date; +} + +const DeploymentProjectSchema = new Schema( + { + name: { type: String, required: true, trim: true }, + rootPath: { type: String, required: true, trim: true }, + repoUrl: { type: String, required: true, trim: true }, + branch: { type: String, required: true, trim: true }, + composeFile: { + type: String, + required: true, + enum: ["docker-compose.yml", "docker-compose.dev.yml"] + }, + webhookToken: { type: String, required: true, unique: true, index: true }, + env: { type: String, required: true, enum: ["dev", "prod"] }, + port: { type: Number }, + lastDeployAt: { type: Date }, + lastStatus: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" }, + lastMessage: { type: String } + }, + { timestamps: true } +); + +DeploymentProjectSchema.index({ rootPath: 1 }); + +export const DeploymentProject = mongoose.model( + "DeploymentProject", + DeploymentProjectSchema +); diff --git a/backend/src/models/deploymentRun.ts b/backend/src/models/deploymentRun.ts new file mode 100644 index 0000000..5f04d33 --- /dev/null +++ b/backend/src/models/deploymentRun.ts @@ -0,0 +1,34 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; +import { DeploymentProjectDocument } from "./deploymentProject.js"; + +export interface DeploymentRunDocument extends Document { + project: Types.ObjectId | DeploymentProjectDocument; + status: "running" | "success" | "failed"; + message?: string; + logs: string[]; + startedAt: Date; + finishedAt?: Date; + durationMs?: number; + createdAt: Date; + updatedAt: Date; +} + +const DeploymentRunSchema = new Schema( + { + project: { type: Schema.Types.ObjectId, ref: "DeploymentProject", required: true }, + status: { type: String, enum: ["running", "success", "failed"], required: true }, + message: { type: String }, + logs: { type: [String], default: [] }, + startedAt: { type: Date, required: true }, + finishedAt: { type: Date }, + durationMs: { type: Number } + }, + { timestamps: true } +); + +DeploymentRunSchema.index({ project: 1, startedAt: -1 }); + +export const DeploymentRun = mongoose.model( + "DeploymentRun", + DeploymentRunSchema +); diff --git a/backend/src/models/settings.ts b/backend/src/models/settings.ts new file mode 100644 index 0000000..8f0f7b1 --- /dev/null +++ b/backend/src/models/settings.ts @@ -0,0 +1,18 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface SettingsDocument extends Document { + webhookToken: string; + webhookSecret: string; + createdAt: Date; + updatedAt: Date; +} + +const SettingsSchema = new Schema( + { + webhookToken: { type: String, required: true }, + webhookSecret: { type: String, required: true } + }, + { timestamps: true } +); + +export const Settings = mongoose.model("Settings", SettingsSchema); diff --git a/backend/src/routes/deployments.ts b/backend/src/routes/deployments.ts new file mode 100644 index 0000000..1b0553e --- /dev/null +++ b/backend/src/routes/deployments.ts @@ -0,0 +1,193 @@ +import { Router } from "express"; +import fs from "fs"; +import path from "path"; +import { authMiddleware } from "../middleware/authMiddleware.js"; +import { deploymentService } from "../services/deploymentService.js"; +import { DeploymentProject } from "../models/deploymentProject.js"; +import { DeploymentRun } from "../models/deploymentRun.js"; + +const router = Router(); + +const faviconCandidates = [ + "favicon.ico", + "public/favicon.ico", + "public/favicon.png", + "public/favicon.svg", + "assets/favicon.ico" +]; + +function getContentType(filePath: string) { + if (filePath.endsWith(".svg")) return "image/svg+xml"; + if (filePath.endsWith(".png")) return "image/png"; + return "image/x-icon"; +} + +router.get("/:id/favicon", async (req, res) => { + const { id } = req.params; + const project = await DeploymentProject.findById(id).lean(); + if (!project) return res.status(404).end(); + const rootPath = path.resolve(project.rootPath); + + for (const candidate of faviconCandidates) { + const filePath = path.join(rootPath, candidate); + if (!fs.existsSync(filePath)) continue; + res.setHeader("Content-Type", getContentType(filePath)); + res.setHeader("Cache-Control", "public, max-age=300"); + return fs.createReadStream(filePath).pipe(res); + } + + return res.status(404).end(); +}); + +router.get("/scan", async (req, res) => { + authMiddleware(req, res, async () => { + try { + const candidates = await deploymentService.scanRoot(); + return res.json(candidates); + } catch (err) { + return res.status(500).json({ message: "Root taraması yapılamadı" }); + } + }); +}); + +router.get("/branches", async (req, res) => { + authMiddleware(req, res, async () => { + const repoUrl = req.query.repoUrl as string | undefined; + if (!repoUrl) { + return res.status(400).json({ message: "repoUrl gerekli" }); + } + try { + const branches = await deploymentService.listRemoteBranches(repoUrl); + return res.json({ branches }); + } catch (err) { + return res.status(400).json({ message: "Branch listesi alınamadı", error: (err as Error).message }); + } + }); +}); + +router.get("/metrics/summary", async (req, res) => { + authMiddleware(req, res, async () => { + const since = new Date(); + since.setDate(since.getDate() - 7); + + const dailyStats = await DeploymentRun.aggregate([ + { $match: { startedAt: { $gte: since } } }, + { + $group: { + _id: { $dateToString: { format: "%Y-%m-%d", date: "$startedAt" } }, + total: { $sum: 1 }, + success: { + $sum: { + $cond: [{ $eq: ["$status", "success"] }, 1, 0] + } + }, + failed: { + $sum: { + $cond: [{ $eq: ["$status", "failed"] }, 1, 0] + } + }, + avgDurationMs: { $avg: "$durationMs" } + } + }, + { $sort: { _id: 1 } } + ]); + + const recentRuns = await DeploymentRun.find() + .sort({ startedAt: -1 }) + .limit(10) + .populate("project", "name repoUrl rootPath") + .lean(); + return res.json({ recentRuns, dailyStats }); + }); +}); + +router.get("/", async (_req, res) => { + authMiddleware(_req, res, async () => { + const projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean(); + return res.json(projects); + }); +}); + +router.get("/:id", async (req, res) => { + authMiddleware(req, res, async () => { + const { id } = req.params; + const project = await DeploymentProject.findById(id).lean(); + if (!project) return res.status(404).json({ message: "Deployment bulunamadı" }); + const runs = await DeploymentRun.find({ project: id }) + .sort({ startedAt: -1 }) + .limit(20) + .lean(); + return res.json({ project, runs }); + }); +}); + +router.post("/", async (req, res) => { + authMiddleware(req, res, async () => { + const { name, rootPath, repoUrl, branch, composeFile, port } = req.body; + if (!name || !rootPath || !repoUrl || !branch || !composeFile) { + return res.status(400).json({ message: "Tüm alanlar gerekli" }); + } + try { + const created = await deploymentService.createProject({ + name, + rootPath, + repoUrl, + branch, + composeFile, + port + }); + return res.status(201).json(created); + } catch (err) { + return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message }); + } + }); +}); + +router.put("/:id", async (req, res) => { + authMiddleware(req, res, async () => { + const { id } = req.params; + const { name, repoUrl, branch, composeFile, port } = req.body; + if (!name || !repoUrl || !branch || !composeFile) { + return res.status(400).json({ message: "Tüm alanlar gerekli" }); + } + try { + const updated = await deploymentService.updateProject(id, { + name, + repoUrl, + branch, + composeFile, + port + }); + if (!updated) return res.status(404).json({ message: "Deployment bulunamadı" }); + return res.json(updated); + } catch (err) { + return res.status(400).json({ message: "Deployment güncellenemedi", error: (err as Error).message }); + } + }); +}); + +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ı" }); + await DeploymentRun.deleteMany({ project: id }); + return res.json({ success: true }); + } catch (err) { + return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message }); + } + }); +}); + +router.post("/:id/run", async (req, res) => { + authMiddleware(req, res, async () => { + 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); + return res.json({ queued: true }); + }); +}); + +export default router; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts new file mode 100644 index 0000000..a9f6415 --- /dev/null +++ b/backend/src/routes/settings.ts @@ -0,0 +1,34 @@ +import { Router } from "express"; +import { authMiddleware } from "../middleware/authMiddleware.js"; +import { deploymentService } from "../services/deploymentService.js"; + +const router = Router(); + +router.use(authMiddleware); + +router.get("/", async (_req, res) => { + const settings = await deploymentService.ensureSettings(); + return res.json({ + webhookToken: settings.webhookToken, + webhookSecret: settings.webhookSecret, + updatedAt: settings.updatedAt + }); +}); + +router.post("/token/rotate", async (_req, res) => { + const settings = await deploymentService.rotateToken(); + return res.json({ + webhookToken: settings.webhookToken, + updatedAt: settings.updatedAt + }); +}); + +router.post("/secret/rotate", async (_req, res) => { + const settings = await deploymentService.rotateSecret(); + return res.json({ + webhookSecret: settings.webhookSecret, + updatedAt: settings.updatedAt + }); +}); + +export default router; diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts new file mode 100644 index 0000000..5e96240 --- /dev/null +++ b/backend/src/routes/webhooks.ts @@ -0,0 +1,66 @@ +import { Router, Request } from "express"; +import crypto from "crypto"; +import { deploymentService } from "../services/deploymentService.js"; + +const router = Router(); + +type RawBodyRequest = Request & { rawBody?: Buffer }; + +function getHeaderValue(value: string | string[] | undefined) { + if (!value) return ""; + return Array.isArray(value) ? value[0] : value; +} + +function verifySignature(rawBody: Buffer, secret: string, signature: string) { + const cleaned = signature.startsWith("sha256=") ? signature.slice(7) : signature; + const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); + if (cleaned.length !== expected.length) return false; + return crypto.timingSafeEqual(Buffer.from(cleaned), Buffer.from(expected)); +} + +router.post("/api/deployments/webhook/:token", async (req, res) => { + const { token } = req.params; + const settings = await deploymentService.ensureSettings(); + + const authHeader = getHeaderValue(req.headers.authorization); + if (!authHeader) { + return res.status(401).json({ message: "Yetkisiz" }); + } + const providedToken = authHeader.startsWith("Bearer ") + ? authHeader.slice("Bearer ".length) + : authHeader; + if (providedToken !== settings.webhookToken) { + return res.status(401).json({ message: "Yetkisiz" }); + } + + const signatureHeader = + getHeaderValue(req.headers["x-gitea-signature"]) || + getHeaderValue(req.headers["x-gitea-signature-256"]); + const rawBody = (req as RawBodyRequest).rawBody; + if (!rawBody || !signatureHeader) { + return res.status(401).json({ message: "Imza eksik" }); + } + if (!verifySignature(rawBody, settings.webhookSecret, signatureHeader)) { + return res.status(401).json({ message: "Imza dogrulanamadi" }); + } + + const payload = req.body as { ref?: string; head_commit?: { message?: string }; commits?: Array<{ message?: string }> }; + const ref = payload?.ref || ""; + const branch = ref.startsWith("refs/heads/") ? ref.replace("refs/heads/", "") : ref; + const commitMessage = + payload?.head_commit?.message || payload?.commits?.[payload.commits.length - 1]?.message; + + const project = await deploymentService.findByWebhookToken(token); + if (!project) return res.status(404).json({ message: "Deployment bulunamadi" }); + + if (branch && branch !== project.branch) { + return res.json({ ignored: true }); + } + + deploymentService + .runDeployment(project._id.toString(), commitMessage ? { message: commitMessage } : undefined) + .catch(() => undefined); + return res.json({ queued: true }); +}); + +export default router; diff --git a/backend/src/services/deploymentService.ts b/backend/src/services/deploymentService.ts new file mode 100644 index 0000000..857637b --- /dev/null +++ b/backend/src/services/deploymentService.ts @@ -0,0 +1,366 @@ +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; +import { spawn } from "child_process"; +import { config } from "../config/env.js"; +import { + DeploymentProject, + DeploymentProjectDocument, + ComposeFile, + DeploymentEnv +} from "../models/deploymentProject.js"; +import { DeploymentRun } from "../models/deploymentRun.js"; +import { Settings } from "../models/settings.js"; + +const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"]; + +function normalizeRoot(rootPath: string) { + return path.resolve(rootPath); +} + +function isWithinRoot(rootPath: string, targetPath: string) { + const resolvedRoot = normalizeRoot(rootPath); + const resolvedTarget = path.resolve(targetPath); + return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`); +} + +function generateWebhookToken() { + return crypto.randomBytes(12).toString("base64url").slice(0, 16); +} + +function generateApiToken() { + return crypto.randomBytes(24).toString("base64url"); +} + +function generateSecret() { + return crypto.randomBytes(32).toString("base64url"); +} + +function deriveEnv(composeFile: ComposeFile): DeploymentEnv { + return composeFile === "docker-compose.dev.yml" ? "dev" : "prod"; +} + +function runCommand(command: string, cwd: string, onData: (chunk: string) => void) { + return new Promise((resolve, reject) => { + const child = spawn(command, { + cwd, + shell: true, + env: { ...process.env, CI: process.env.CI || "1" } + }); + + const emitLines = (chunk: Buffer) => { + const cleaned = chunk.toString().replace(/\r\n|\r/g, "\n"); + cleaned.split("\n").forEach((line) => { + if (line.trim().length > 0) onData(line); + }); + }; + + child.stdout.on("data", emitLines); + child.stderr.on("data", emitLines); + + child.on("error", (err) => { + onData(`Hata: ${err.message}`); + reject(err); + }); + + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Komut kod ${code} ile kapandı`)); + } + }); + }); +} + +function runCommandCapture(command: string, args: string[], cwd: string) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd }); + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + child.on("error", (err) => { + reject(err); + }); + + child.on("close", (code) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(stderr.trim() || `Komut kod ${code} ile kapandı`)); + } + }); + }); +} + +async function ensureSafeDirectory(repoDir: string, onData: (line: string) => void) { + onData(`Git safe.directory ekleniyor: ${repoDir}`); + await runCommand(`git config --global --add safe.directory ${repoDir}`, process.cwd(), onData); +} + +async function ensureRepo(project: DeploymentProjectDocument, onData: (line: string) => void) { + const repoDir = project.rootPath; + const gitDir = path.join(repoDir, ".git"); + const exists = fs.existsSync(gitDir); + + await ensureSafeDirectory(repoDir, onData); + + if (!exists) { + const entries = await fs.promises.readdir(repoDir); + if (entries.length > 0) { + throw new Error("Repo klasoru git olmayan dosyalar iceriyor"); + } + onData(`Repo klonlanıyor: ${project.repoUrl}`); + await runCommand(`git clone --branch ${project.branch} ${project.repoUrl} .`, repoDir, onData); + } else { + onData("Repo güncelleniyor (git fetch/pull)..."); + await runCommand(`git fetch origin ${project.branch}`, repoDir, onData); + try { + await runCommand(`git checkout ${project.branch}`, repoDir, onData); + } catch { + await runCommand(`git checkout -b ${project.branch} origin/${project.branch}`, repoDir, onData); + } + await runCommand(`git pull origin ${project.branch}`, repoDir, onData); + } +} + +async function runCompose( + project: DeploymentProjectDocument, + onData: (line: string) => void +) { + const composePath = path.join(project.rootPath, project.composeFile); + if (!fs.existsSync(composePath)) { + throw new Error("Compose dosyası bulunamadı"); + } + onData("Docker compose down çalıştırılıyor..."); + await runCommand(`docker compose -f ${project.composeFile} down`, project.rootPath, onData); + onData("Docker compose up (build) çalıştırılıyor..."); + await runCommand( + `docker compose -f ${project.composeFile} up -d --build`, + project.rootPath, + onData + ); +} + +class DeploymentService { + private running: Map = new Map(); + + async scanRoot() { + const rootPath = normalizeRoot(config.deploymentsRoot); + if (!fs.existsSync(rootPath)) { + throw new Error("Deployments root bulunamadı"); + } + const entries = await fs.promises.readdir(rootPath, { withFileTypes: true }); + const candidates = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith(".")) continue; + const folderPath = path.join(rootPath, entry.name); + const available = composeFileCandidates.filter((file) => + fs.existsSync(path.join(folderPath, file)) + ); + if (available.length === 0) continue; + candidates.push({ + name: entry.name, + rootPath: folderPath, + composeFiles: available + }); + } + return candidates; + } + + async listRemoteBranches(repoUrl: string) { + const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd()); + const branches = output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.split("\t")[1] || "") + .filter((ref) => ref.startsWith("refs/heads/")) + .map((ref) => ref.replace("refs/heads/", "")); + return branches; + } + + async ensureSettings() { + const existing = await Settings.findOne(); + if (existing) return existing; + const created = await Settings.create({ + webhookToken: generateApiToken(), + webhookSecret: generateSecret() + }); + return created; + } + + async rotateToken() { + const settings = await this.ensureSettings(); + settings.webhookToken = generateApiToken(); + await settings.save(); + return settings; + } + + async rotateSecret() { + const settings = await this.ensureSettings(); + settings.webhookSecret = generateSecret(); + await settings.save(); + return settings; + } + + async createProject(input: { + name: string; + rootPath: string; + repoUrl: string; + branch: string; + composeFile: ComposeFile; + port?: number; + }) { + const rootPath = path.resolve(input.rootPath); + if (!isWithinRoot(config.deploymentsRoot, rootPath)) { + throw new Error("Root path deployments root dışında"); + } + if (!fs.existsSync(rootPath)) { + throw new Error("Root path bulunamadı"); + } + const composePath = path.join(rootPath, input.composeFile); + if (!fs.existsSync(composePath)) { + throw new Error("Compose dosyası bulunamadı"); + } + + const existing = await DeploymentProject.findOne({ rootPath }); + if (existing) { + throw new Error("Bu klasör zaten eklenmiş"); + } + + let webhookToken = generateWebhookToken(); + while (await DeploymentProject.findOne({ webhookToken })) { + webhookToken = generateWebhookToken(); + } + + const env = deriveEnv(input.composeFile); + return DeploymentProject.create({ + name: input.name, + rootPath, + repoUrl: input.repoUrl, + branch: input.branch, + composeFile: input.composeFile, + webhookToken, + env, + port: input.port + }); + } + + async updateProject( + id: string, + input: { + name: string; + repoUrl: string; + branch: string; + composeFile: ComposeFile; + port?: number; + } + ) { + const project = await DeploymentProject.findById(id); + if (!project) return null; + const composePath = path.join(project.rootPath, input.composeFile); + if (!fs.existsSync(composePath)) { + throw new Error("Compose dosyası bulunamadı"); + } + const env = deriveEnv(input.composeFile); + const updated = await DeploymentProject.findByIdAndUpdate( + id, + { + name: input.name, + repoUrl: input.repoUrl, + branch: input.branch, + composeFile: input.composeFile, + env, + port: input.port + }, + { new: true, runValidators: true } + ); + return updated; + } + + async runDeployment(projectId: string, options?: { message?: string }) { + if (this.running.get(projectId)) { + return; + } + this.running.set(projectId, true); + + const project = await DeploymentProject.findById(projectId); + if (!project) { + this.running.delete(projectId); + return; + } + + const startedAt = Date.now(); + const runLogs: string[] = []; + const pushLog = (line: string) => { + runLogs.push(line); + }; + + const runDoc = await DeploymentRun.create({ + project: projectId, + status: "running", + startedAt: new Date(), + message: options?.message + }); + + await DeploymentProject.findByIdAndUpdate(projectId, { + lastStatus: "running", + lastMessage: options?.message || "Deploy başlıyor..." + }); + + try { + await ensureRepo(project, (line) => pushLog(line)); + pushLog("Deploy komutları çalıştırılıyor..."); + await runCompose(project, (line) => pushLog(line)); + const duration = Date.now() - startedAt; + await DeploymentProject.findByIdAndUpdate(projectId, { + lastStatus: "success", + lastDeployAt: new Date(), + lastMessage: options?.message || "Başarılı" + }); + await DeploymentRun.findByIdAndUpdate(runDoc._id, { + status: "success", + finishedAt: new Date(), + durationMs: duration, + logs: runLogs, + message: options?.message + }); + pushLog("Deploy tamamlandı: Başarılı"); + } catch (err) { + const duration = Date.now() - startedAt; + await DeploymentProject.findByIdAndUpdate(projectId, { + lastStatus: "failed", + lastDeployAt: new Date(), + lastMessage: (err as Error).message + }); + await DeploymentRun.findByIdAndUpdate(runDoc._id, { + status: "failed", + finishedAt: new Date(), + durationMs: duration, + logs: runLogs, + message: options?.message + }); + pushLog(`Hata: ${(err as Error).message}`); + } finally { + this.running.delete(projectId); + } + } + + async findByWebhookToken(token: string) { + return DeploymentProject.findOne({ webhookToken: token }); + } +} + +export const deploymentService = new DeploymentService(); +export { generateApiToken, generateSecret }; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e849c44..1b8d5df 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,6 +13,8 @@ services: volumes: - ./backend:/app - /app/node_modules + - ${DEPLOYMENTS_ROOT_HOST}:/workspace + - /var/run/docker.sock:/var/run/docker.sock env_file: - ./backend/.env ports: diff --git a/docker-compose.yml b/docker-compose.yml index 2f95df1..aa40d6d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,9 @@ services: backend: build: ./backend command: npm run build && npm start + volumes: + - ${DEPLOYMENTS_ROOT_HOST}:/workspace + - /var/run/docker.sock:/var/run/docker.sock env_file: - ./backend/.env ports: diff --git a/frontend/.env.example b/frontend/.env.example index 2205fd6..0d2d79c 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,3 @@ -VITE_API_URL=http://localhost:4000 +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/App.tsx b/frontend/src/App.tsx index fabf857..c01974e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,9 @@ import { DashboardLayout } from "./components/DashboardLayout"; import { HomePage } from "./pages/HomePage"; import { JobsPage } from "./pages/JobsPage"; import { JobDetailPage } from "./pages/JobDetailPage"; +import { DeploymentsPage } from "./pages/DeploymentsPage"; +import { DeploymentDetailPage } from "./pages/DeploymentDetailPage"; +import { SettingsPage } from "./pages/SettingsPage"; function App() { return ( @@ -15,6 +18,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/api/deployments.ts b/frontend/src/api/deployments.ts new file mode 100644 index 0000000..fdfe89b --- /dev/null +++ b/frontend/src/api/deployments.ts @@ -0,0 +1,115 @@ +import { apiClient } from "./client"; + +export type ComposeFile = "docker-compose.yml" | "docker-compose.dev.yml"; +export type DeploymentStatus = "idle" | "running" | "success" | "failed"; +export type DeploymentEnv = "dev" | "prod"; + +export interface DeploymentProject { + _id: string; + name: string; + rootPath: string; + repoUrl: string; + branch: string; + composeFile: ComposeFile; + webhookToken: string; + env: DeploymentEnv; + port?: number; + lastDeployAt?: string; + lastStatus: DeploymentStatus; + lastMessage?: string; + createdAt: string; + updatedAt: string; +} + +export interface DeploymentRun { + _id: string; + project: string; + status: "running" | "success" | "failed"; + message?: string; + logs: string[]; + startedAt: string; + finishedAt?: string; + durationMs?: number; + createdAt: string; + updatedAt: string; +} + +export interface DeploymentRunWithProject extends Omit { + project: DeploymentProject; +} + +export interface DeploymentDetailResponse { + project: DeploymentProject; + runs: DeploymentRun[]; +} + +export interface DeploymentMetrics { + dailyStats: Array<{ + _id: string; + total: number; + success: number; + failed: number; + avgDurationMs?: number; + }>; + recentRuns: DeploymentRunWithProject[]; +} + +export interface DeploymentCandidate { + name: string; + rootPath: string; + composeFiles: ComposeFile[]; +} + +export interface DeploymentInput { + name: string; + rootPath: string; + repoUrl: string; + branch: string; + composeFile: ComposeFile; + port?: number; +} + +export async function fetchDeployments(): Promise { + const { data } = await apiClient.get("/deployments"); + return data as DeploymentProject[]; +} + +export async function fetchDeploymentBranches(repoUrl: string): Promise { + const { data } = await apiClient.get("/deployments/branches", { + params: { repoUrl } + }); + return (data as { branches: string[] }).branches; +} + +export async function scanDeployments(): Promise { + const { data } = await apiClient.get("/deployments/scan"); + return data as DeploymentCandidate[]; +} + +export async function createDeployment(payload: DeploymentInput): Promise { + const { data } = await apiClient.post("/deployments", payload); + return data as DeploymentProject; +} + +export async function updateDeployment(id: string, payload: Omit) { + const { data } = await apiClient.put(`/deployments/${id}`, payload); + return data as DeploymentProject; +} + +export async function deleteDeployment(id: string): Promise { + await apiClient.delete(`/deployments/${id}`); +} + +export async function runDeployment(id: string): Promise { + await apiClient.post(`/deployments/${id}/run`); +} + +export async function fetchDeployment(id: string): Promise { + const { data } = await apiClient.get(`/deployments/${id}`); + return data as DeploymentDetailResponse; +} + +export async function fetchDeploymentMetrics(): Promise { + const { data } = await apiClient.get("/deployments/metrics/summary"); + return data as DeploymentMetrics; +} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 0000000..685f47d --- /dev/null +++ b/frontend/src/api/settings.ts @@ -0,0 +1,22 @@ +import { apiClient } from "./client"; + +export interface SettingsResponse { + webhookToken: string; + webhookSecret: string; + updatedAt: string; +} + +export async function fetchSettings(): Promise { + const { data } = await apiClient.get("/settings"); + return data as SettingsResponse; +} + +export async function rotateWebhookToken(): Promise { + const { data } = await apiClient.post("/settings/token/rotate"); + return data as SettingsResponse; +} + +export async function rotateWebhookSecret(): Promise { + const { data } = await apiClient.post("/settings/secret/rotate"); + return data as SettingsResponse; +} diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index 069b7e3..92f9414 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -1,7 +1,14 @@ import React, { useMemo, useState } from "react"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faHouse, faBriefcase, faArrowRightFromBracket, faUser, faFlaskVial } from "@fortawesome/free-solid-svg-icons"; +import { + faHouse, + faArrowRightFromBracket, + faUser, + faFlaskVial, + faRocket, + faGear +} from "@fortawesome/free-solid-svg-icons"; import { Button } from "./ui/button"; import { ThemeToggle } from "./ThemeToggle"; import { useAuth } from "../providers/auth-provider"; @@ -15,7 +22,9 @@ export function DashboardLayout() { const navigation = useMemo( () => [ { label: "Home", to: "/home", icon: faHouse }, - { label: "Jobs", to: "/jobs", icon: faFlaskVial } + { label: "Jobs", to: "/jobs", icon: faFlaskVial }, + { label: "Deployments", to: "/deployments", icon: faRocket }, + { label: "Settings", to: "/settings", icon: faGear } ], [] ); diff --git a/frontend/src/pages/DeploymentDetailPage.tsx b/frontend/src/pages/DeploymentDetailPage.tsx new file mode 100644 index 0000000..fb81374 --- /dev/null +++ b/frontend/src/pages/DeploymentDetailPage.tsx @@ -0,0 +1,221 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowLeft, faCloudArrowUp, faCopy, faHistory } from "@fortawesome/free-solid-svg-icons"; +import { toast } from "sonner"; +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"; + +export function DeploymentDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [project, setProject] = useState(null); + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(true); + const [triggering, setTriggering] = useState(false); + + useEffect(() => { + if (!id) return; + fetchDeployment(id) + .then((data) => { + setProject(data.project); + setRuns(data.runs); + }) + .catch(() => toast.error("Deployment bulunamadı")) + .finally(() => setLoading(false)); + }, [id]); + + const webhookUrl = useMemo(() => { + if (!project) return ""; + return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`; + }, [project]); + + const latestRun = runs[0]; + + const decorateLogLine = (line: string) => { + const lower = line.toLowerCase(); + if (lower.includes("error") || lower.includes("fail") || lower.includes("hata")) { + return `❌ ${line}`; + } + if (lower.includes("success") || lower.includes("başarılı") || lower.includes("completed")) { + return `✅ ${line}`; + } + if (lower.includes("docker")) { + return `🐳 ${line}`; + } + if (lower.includes("git")) { + return `🔧 ${line}`; + } + if (lower.includes("clone") || lower.includes("pull") || lower.includes("fetch")) { + return `📦 ${line}`; + } + return `• ${line}`; + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(webhookUrl); + toast.success("Webhook URL kopyalandı"); + } catch { + toast.error("Webhook URL kopyalanamadı"); + } + }; + + const handleRun = async () => { + if (!id) return; + setTriggering(true); + try { + await runDeployment(id); + toast.success("Deploy tetiklendi"); + } catch { + toast.error("Deploy tetiklenemedi"); + } finally { + setTriggering(false); + } + }; + + if (loading) { + return ( +
+ Deployment yükleniyor... +
+ ); + } + + if (!project) { + return ( +
+ Deployment bulunamadı. +
+ ); + } + + return ( +
+
+
+ +
+

{project.name}

+
{project.rootPath}
+
+
+
+ + +
+
+ + + + Genel Bilgiler + + + +
+ Repo: + {project.repoUrl} +
+
+ Branch: + {project.branch} +
+
+ Compose: + {project.composeFile} +
+
+ Env: + + {project.env.toUpperCase()} + +
+
+ Last Deploy: + + {project.lastDeployAt ? new Date(project.lastDeployAt).toLocaleString() : "-"} + +
+
+
+ + + + Webhook URL + + +
+ {webhookUrl} + +
+
+
+ + + + + + Deploy Geçmişi + + + + {runs.length === 0 && ( +
Henüz deploy çalıştırılmadı.
+ )} + {runs.map((run) => ( +
+
+ + + {new Date(run.startedAt).toLocaleString()} + + {run.message && ( + · {run.message} + )} +
+
+ {run.durationMs ? `${Math.round(run.durationMs / 1000)}s` : "-"} +
+
+ ))} +
+
+ + + + Son Deploy Logları + + +
+ {latestRun?.logs?.length ? ( + latestRun.logs.map((line, idx) => ( +
+ {decorateLogLine(line)} +
+ )) + ) : ( +
Henüz log yok.
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/DeploymentsPage.tsx b/frontend/src/pages/DeploymentsPage.tsx new file mode 100644 index 0000000..00d49b1 --- /dev/null +++ b/frontend/src/pages/DeploymentsPage.tsx @@ -0,0 +1,532 @@ +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useLocation, useNavigate } from "react-router-dom"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCloudArrowUp, + faPlus, + faRotate, + faRocket +} from "@fortawesome/free-solid-svg-icons"; +import { Card, CardContent } from "../components/ui/card"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { Label } from "../components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; +import { + createDeployment, + deleteDeployment, + DeploymentCandidate, + DeploymentInput, + DeploymentProject, + fetchDeploymentBranches, + fetchDeployments, + runDeployment, + scanDeployments, + updateDeployment +} from "../api/deployments"; +import { JobStatusBadge } from "../components/JobStatusBadge"; + +type FormState = { + _id?: string; + name: string; + rootPath: string; + repoUrl: string; + branch: string; + composeFile: DeploymentInput["composeFile"]; + port: string; +}; + +const defaultForm: FormState = { + name: "", + rootPath: "", + repoUrl: "", + branch: "main", + composeFile: "docker-compose.yml", + port: "" +}; + +export function DeploymentsPage() { + const navigate = useNavigate(); + const location = useLocation(); + const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, ""); + const [deployments, setDeployments] = useState([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [scanning, setScanning] = useState(false); + const [candidates, setCandidates] = useState([]); + const [form, setForm] = useState(defaultForm); + const [pendingEditId, setPendingEditId] = useState(null); + const [branchOptions, setBranchOptions] = useState([]); + const [branchLoading, setBranchLoading] = useState(false); + const [faviconErrors, setFaviconErrors] = useState>({}); + + const isEdit = useMemo(() => !!form._id, [form._id]); + const selectedCandidate = useMemo( + () => candidates.find((c) => c.rootPath === form.rootPath), + [candidates, form.rootPath] + ); + + const loadDeployments = async () => { + setLoading(true); + try { + const data = await fetchDeployments(); + setDeployments(data); + } catch { + toast.error("Deployment listesi alınamadı"); + } finally { + setLoading(false); + } + }; + + const loadCandidates = async () => { + setScanning(true); + try { + const data = await scanDeployments(); + setCandidates(data); + } catch { + toast.error("Root taraması yapılamadı"); + } finally { + setScanning(false); + } + }; + + useEffect(() => { + loadDeployments(); + }, []); + + useEffect(() => { + const repoUrl = form.repoUrl.trim(); + if (!repoUrl) { + setBranchOptions([]); + return; + } + const timer = setTimeout(async () => { + setBranchLoading(true); + try { + const branches = await fetchDeploymentBranches(repoUrl); + setBranchOptions(branches); + if (!form.branch && branches.length > 0) { + setForm((prev) => ({ ...prev, branch: branches.includes("main") ? "main" : branches[0] })); + } + } catch { + setBranchOptions([]); + } finally { + setBranchLoading(false); + } + }, 400); + return () => clearTimeout(timer); + }, [form.repoUrl, form.branch]); + + useEffect(() => { + const state = location.state as { editDeploymentId?: string } | null; + if (state?.editDeploymentId) { + setPendingEditId(state.editDeploymentId); + navigate(location.pathname, { replace: true }); + } + }, [location.state, navigate, location.pathname]); + + useEffect(() => { + if (!pendingEditId || deployments.length === 0) return; + const deployment = deployments.find((d) => d._id === pendingEditId); + if (deployment) { + handleEdit(deployment); + setPendingEditId(null); + } + }, [pendingEditId, deployments]); + + const handleOpenNew = async () => { + setForm(defaultForm); + setBranchOptions([]); + setModalOpen(true); + await loadCandidates(); + }; + + const handleEdit = (deployment: DeploymentProject) => { + const { _id, name, rootPath, repoUrl, branch, composeFile, port } = deployment; + setForm({ + _id, + name, + rootPath, + repoUrl, + branch, + composeFile, + port: port ? String(port) : "" + }); + setModalOpen(true); + }; + + const handleClose = () => { + setModalOpen(false); + }; + + const handleSave = async () => { + setSaving(true); + try { + const payload: DeploymentInput = { + name: form.name, + rootPath: form.rootPath, + repoUrl: form.repoUrl, + branch: form.branch, + composeFile: form.composeFile, + port: form.port ? Number(form.port) : undefined + }; + + if (!payload.name || !payload.rootPath || !payload.repoUrl || !payload.branch || !payload.composeFile) { + toast.error("Tüm alanları doldurun"); + setSaving(false); + return; + } + + if (isEdit && form._id) { + const updated = await updateDeployment(form._id, { + name: payload.name, + repoUrl: payload.repoUrl, + branch: payload.branch, + composeFile: payload.composeFile, + port: payload.port + }); + setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d))); + toast.success("Deployment güncellendi"); + } else { + const created = await createDeployment(payload); + setDeployments((prev) => [created, ...prev]); + toast.success("Deployment oluşturuldu"); + } + + setModalOpen(false); + } catch { + toast.error("İşlem sırasında hata oluştu"); + } finally { + setSaving(false); + } + }; + + const handleRun = async (id: string) => { + try { + await runDeployment(id); + toast.success("Deploy tetiklendi"); + } catch { + toast.error("Deploy tetiklenemedi"); + } + }; + + const handleDelete = async (deployment: DeploymentProject) => { + const ok = window.confirm("Bu deployment'ı silmek istediğinize emin misiniz?"); + if (!ok) return; + try { + await deleteDeployment(deployment._id); + setDeployments((prev) => prev.filter((d) => d._id !== deployment._id)); + toast.success("Deployment silindi"); + } catch { + toast.error("Deployment silinemedi"); + } + }; + + const formatDate = (value?: string) => { + if (!value) return "-"; + return new Date(value).toLocaleString(); + }; + + return ( + <> +
+
+

Deployments

+
+ +
+ +
+ {loading && ( +
+ Deployments yükleniyor... +
+ )} + + {!loading && deployments.length === 0 && ( +
+ Henüz deployment eklenmemiş. +
+ )} + + {deployments.map((deployment) => { + const faviconUrl = apiBase + ? `${apiBase}/deployments/${deployment._id}/favicon` + : `/api/deployments/${deployment._id}/favicon`; + return ( + navigate(`/deployments/${deployment._id}`)} + > + +
+
+
+
+ {!faviconErrors[deployment._id] ? ( + {`${deployment.name} + setFaviconErrors((prev) => ({ ...prev, [deployment._id]: true })) + } + /> + ) : ( + + )} +
+
+
{deployment.name}
+
{deployment.rootPath}
+
+
+
+ + + {deployment.env.toUpperCase()} + + + {deployment.composeFile} + +
+
+ +
+ + + +
+
+ +
+
+ Repo: + {deployment.repoUrl} +
+
+ Branch: + {deployment.branch} +
+
+ Last Deploy: + {formatDate(deployment.lastDeployAt)} +
+
+
+
+ ); + })} +
+ + {modalOpen && ( +
+
+
+
+
+ {isEdit ? "Deployment Güncelle" : "Yeni Deployment"} +
+
+ Repo ve branch seçimi sonrası webhook tetiklemeleriyle deploy yapılır. +
+
+ +
+ +
+ {!isEdit && ( +
+
+ + +
+ +
+ {scanning + ? "Root dizin taranıyor..." + : candidates.length === 0 + ? "Root altında compose dosyası bulunan proje yok." + : "Compose dosyası bulunan klasörleri listeler."} +
+
+ )} + +
+ + setForm((prev) => ({ ...prev, repoUrl: e.target.value }))} + placeholder="https://gitea.example.com/org/repo" + required + /> +
+ +
+
+ + setForm((prev) => ({ ...prev, name: e.target.value }))} + placeholder="wisecolt-app" + required + /> +
+
+ + {branchOptions.length > 0 ? ( + + ) : ( + setForm((prev) => ({ ...prev, branch: e.target.value }))} + placeholder="main" + required + /> + )} +
+ {branchLoading + ? "Branch listesi alınıyor..." + : branchOptions.length > 0 + ? "Repo üzerindeki branch'lar listelendi." + : "Repo URL girildiğinde branch listesi otomatik gelir."} +
+
+
+ +
+
+ + +
+ +
+ + setForm((prev) => ({ ...prev, port: e.target.value }))} + placeholder="3000" + /> +
+
+
+ +
+ + +
+
+
+ )} + + ); +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 60839f4..76474f6 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -13,10 +13,11 @@ import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; import { useLiveData } from "../providers/live-provider"; import { fetchJobMetrics, JobMetrics } from "../api/jobs"; +import { fetchDeploymentMetrics, DeploymentMetrics, DeploymentRunWithProject } from "../api/deployments"; import { JobStatusBadge } from "../components/JobStatusBadge"; import { RepoIcon } from "../components/RepoIcon"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faClockRotateLeft, faListCheck } from "@fortawesome/free-solid-svg-icons"; +import { faClockRotateLeft, faListCheck, faFlaskVial, faRocket } from "@fortawesome/free-solid-svg-icons"; function formatDuration(ms?: number) { if (!ms || Number.isNaN(ms)) return "-"; @@ -29,28 +30,79 @@ function formatDuration(ms?: number) { return `${hours}sa ${minutes % 60}dk`; } +function toYmd(date: Date) { + return date.toISOString().slice(0, 10); +} + export function HomePage() { const [metrics, setMetrics] = useState(null); + const [deploymentMetrics, setDeploymentMetrics] = useState(null); + const [deployRuns, setDeployRuns] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const { jobStreams } = useLiveData(); const navigate = useNavigate(); useEffect(() => { - fetchJobMetrics() - .then(setMetrics) - .catch(() => setError("Metrikler alınamadı")) + Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()]) + .then(([jobResult, deployResult]) => { + if (jobResult.status === "fulfilled") { + setMetrics(jobResult.value); + } else { + setMetrics({ + dailyStats: [], + recentRuns: [], + totals: { successRate: 0, totalRuns: 0 } + }); + setError("Job metrikleri alınamadı"); + } + + if (deployResult.status === "fulfilled") { + setDeploymentMetrics(deployResult.value); + setDeployRuns(deployResult.value.recentRuns || []); + } else { + setDeploymentMetrics({ dailyStats: [], recentRuns: [] }); + } + }) .finally(() => setLoading(false)); }, []); const chartData = useMemo(() => { - if (!metrics) return []; - return metrics.dailyStats.map((d) => ({ - date: d._id, - Başarılı: d.success, - Hatalı: d.failed - })); - }, [metrics]); + if (!metrics) { + const days = Array.from({ length: 7 }).map((_, idx) => { + const date = new Date(); + date.setDate(date.getDate() - (6 - idx)); + return toYmd(date); + }); + return days.map((date) => ({ + date, + "Test Başarılı": 0, + "Test Hatalı": 0, + "Deploy Başarılı": 0, + "Deploy Hatalı": 0 + })); + } + const deployMap = new Map((deploymentMetrics?.dailyStats || []).map((d) => [d._id, d])); + const jobMap = new Map(metrics.dailyStats.map((d) => [d._id, d])); + + const days = Array.from({ length: 7 }).map((_, idx) => { + const date = new Date(); + date.setDate(date.getDate() - (6 - idx)); + return toYmd(date); + }); + + return days.map((date) => { + const job = jobMap.get(date); + const deploy = deployMap.get(date); + return { + date, + "Test Başarılı": job?.success || 0, + "Test Hatalı": job?.failed || 0, + "Deploy Başarılı": deploy?.success || 0, + "Deploy Hatalı": deploy?.failed || 0 + }; + }); + }, [metrics, deploymentMetrics]); const mergedRuns = useMemo(() => { if (!metrics) return []; @@ -69,6 +121,35 @@ export function HomePage() { }); }, [metrics, jobStreams]); + const activityItems = useMemo(() => { + const jobItems = mergedRuns.map((run) => ({ + id: run._id, + type: "test" as const, + title: run.job.name, + repoUrl: run.job.repoUrl, + status: run.status, + startedAt: run.startedAt, + durationMs: run.durationMs, + link: `/jobs/${run.job._id}` + })); + + const deployItems = deployRuns.map((run) => ({ + id: run._id, + type: "deploy" as const, + title: run.project.name, + repoUrl: run.project.repoUrl, + status: run.status, + startedAt: run.startedAt, + durationMs: run.durationMs, + message: run.message, + link: `/deployments/${run.project._id}` + })); + + return [...jobItems, ...deployItems] + .sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()) + .slice(0, 10); + }, [mergedRuns, deployRuns]); + const lastRunDuration = useMemo(() => formatDuration(mergedRuns[0]?.durationMs), [mergedRuns]); return ( @@ -78,14 +159,14 @@ export function HomePage() {
Son 7 Gün Çalıştırma Trendleri - Başarılı / Hatalı job sayıları + Test ve Deploy sonuçları
{metrics?.totals.totalRuns ?? 0} toplam koşu
- + {loading ? (
Yükleniyor...
) : chartData.length === 0 ? ( @@ -96,10 +177,24 @@ export function HomePage() { - + - - + + + + )} @@ -111,7 +206,7 @@ export function HomePage() { Hızlı Metrikler Özet görünüm - +
Başarı Oranı @@ -136,33 +231,50 @@ export function HomePage() {
Etkinlik Akışı - Son 10 job çalıştırması + Son 10 aktivite
- {mergedRuns.length ?? 0} kayıt + {activityItems.length ?? 0} kayıt
{loading &&
Yükleniyor...
} {error &&
{error}
} - {!loading && mergedRuns.length === 0 && ( + {!loading && activityItems.length === 0 && (
Henüz çalıştırma yok.
)} {!loading && - mergedRuns.map((run) => ( + activityItems.map((run) => ( +
+ + +
+ + {showToken ? settings.webhookToken : "•".repeat(settings.webhookToken.length)} + +
+ + +
+
+
+ + + + + Webhook Secret +
+ +
+
+ +
+ + {showSecret ? settings.webhookSecret : "•".repeat(settings.webhookSecret.length)} + +
+ + +
+
+
+
+ + ); +}