Projeleri otomatik deployment etme özelliği eklendi.

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-01-18 13:40:52 +00:00
24 changed files with 2042 additions and 37 deletions

12
.env.example Normal file
View File

@@ -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

View File

@@ -28,6 +28,13 @@
- **Test Sonuçları**: Başarılı/başarısız sonuçların kaydedilmesi
- **Log Akışı**: Gerçek zamanlı test loglarının izlenmesi
### 🚀 Deployment Yönetimi
- **Root Tarama**: `DEPLOYMENTS_ROOT_HOST` altında compose dosyası olan projeleri otomatik bulma
- **Webhook Tetikleme**: Gitea push event ile otomatik deploy
- **Branch Seçimi**: Repo URL girince branch listesi alınır ve seçim yapılır
- **Deploy Geçmişi**: Her deploy için log ve süre kaydı
- **Güvenlik**: API Token + Webhook Secret ile doğrulama
### ⚡ Gerçek Zamanlı İletişim
- **WebSocket Bağlantısı**: Socket.io ile sürekli iletişim
- **Sayaç Yayınınlaması**: Global sayaç ve işlemler
@@ -92,10 +99,15 @@ wisecolt-ci/
│ │ ├── 📁 middleware/ # Express middleware'leri
│ │ │ └── authMiddleware.ts
│ │ ├── 📁 models/ # MongoDB modelleri
│ │ │ ── job.ts
│ │ │ ── job.ts
│ │ │ ├── deploymentProject.ts
│ │ │ ├── deploymentRun.ts
│ │ │ └── settings.ts
│ │ ├── 📁 routes/ # API route'ları
│ │ │ ├── auth.ts
│ │ │ ── jobs.ts
│ │ │ ── jobs.ts
│ │ │ ├── deployments.ts
│ │ │ └── webhooks.ts
│ │ ├── 📁 services/ # İş mantığı katmanı
│ │ │ └── jobService.ts
│ │ └── 📄 index.ts # Ana sunucu dosyası
@@ -120,7 +132,10 @@ wisecolt-ci/
│ │ ├── 📁 pages/ # Sayfa bileşenleri
│ │ │ ├── HomePage.tsx
│ │ │ ├── JobsPage.tsx
│ │ │ ── JobDetailPage.tsx
│ │ │ ── JobDetailPage.tsx
│ │ │ ├── DeploymentsPage.tsx
│ │ │ ├── DeploymentDetailPage.tsx
│ │ │ └── SettingsPage.tsx
│ │ ├── 📁 providers/ # React Context Provider'lar
│ │ │ ├── auth-provider.tsx
│ │ │ ├── socket-provider.tsx
@@ -206,6 +221,19 @@ docker compose up -d --build
- **Log Akışı**: Test çıktılarını canlı izleme
- **Manuel Çalıştırma**: Job'u anında tetikleme
### Deployment Yönetimi
1. **Deployments** sayfasına gidin
2. **New Deployment** ile root altında taranan projeyi seçin
3. Repo URL + Branch + Compose dosyasını girin
4. Kaydettikten sonra **Webhook URL**i Giteada web istemci olarak tanımlayın
#### Webhook Ayarları (Gitea)
- **Hedef URL**: `https://<domain>/api/deployments/webhook/<token>`
- **Yetkilendirme Başlığı**: `Bearer <API_TOKEN>`
- **Gizli**: `WEBHOOK_SECRET`
- **HTTP Yöntemi**: `POST`
- **İçerik Türü**: `application/json`
### Authentication
#### Token Yönetimi
@@ -223,6 +251,8 @@ docker compose up -d --build
### 📖 API Referansı
- **Authentication API'leri**: `/auth/login`, `/auth/me`
- **Job Yönetim API'leri**: CRUD operasyonları, manuel çalıştırma
- **Deployment API'leri**: `/deployments`, `/deployments/:id`, `/deployments/scan`, `/deployments/branches`
- **Webhook Endpoint**: `/api/deployments/webhook/:token`
- **WebSocket Olayları**: Real-time iletişim ve durum güncellemeleri
- **Endpoint Detayları**: Her endpoint için istek/yanıt formatları
@@ -319,8 +349,11 @@ ADMIN_PASSWORD=supersecret # Admin şifresi
JWT_SECRET=gizli-jwt-anahtari # JWT imzalama anahtarı
CLIENT_ORIGIN=http://localhost:5173 # Frontend adresi (CORS için)
# Docker Compose (.env)
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace # Zorunlu: host proje dizini
# Frontend (.env)
VITE_API_URL=http://localhost:4000 # Backend API adresi
VITE_API_URL=http://localhost:4000/api # Backend API adresi
```
### Port Yapılandırması

View File

@@ -2,8 +2,8 @@ PORT=4000
# Prod için zorunlu Mongo bağlantısı
# Örnek: mongodb://<APP_USER>:<APP_PASS>@<HOST>: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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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<DeploymentProjectDocument>(
{
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<DeploymentProjectDocument>(
"DeploymentProject",
DeploymentProjectSchema
);

View File

@@ -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<DeploymentRunDocument>(
{
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<DeploymentRunDocument>(
"DeploymentRun",
DeploymentRunSchema
);

View File

@@ -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<SettingsDocument>(
{
webhookToken: { type: String, required: true },
webhookSecret: { type: String, required: true }
},
{ timestamps: true }
);
export const Settings = mongoose.model<SettingsDocument>("Settings", SettingsSchema);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<void>((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<string>((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<string, boolean> = 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 };

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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() {
<Route path="/home" element={<HomePage />} />
<Route path="/jobs" element={<JobsPage />} />
<Route path="/jobs/:id" element={<JobDetailPage />} />
<Route path="/deployments" element={<DeploymentsPage />} />
<Route path="/deployments/:id" element={<DeploymentDetailPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/home" replace />} />
</Route>
</Route>

View File

@@ -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<DeploymentRun, "project"> {
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<DeploymentProject[]> {
const { data } = await apiClient.get("/deployments");
return data as DeploymentProject[];
}
export async function fetchDeploymentBranches(repoUrl: string): Promise<string[]> {
const { data } = await apiClient.get("/deployments/branches", {
params: { repoUrl }
});
return (data as { branches: string[] }).branches;
}
export async function scanDeployments(): Promise<DeploymentCandidate[]> {
const { data } = await apiClient.get("/deployments/scan");
return data as DeploymentCandidate[];
}
export async function createDeployment(payload: DeploymentInput): Promise<DeploymentProject> {
const { data } = await apiClient.post("/deployments", payload);
return data as DeploymentProject;
}
export async function updateDeployment(id: string, payload: Omit<DeploymentInput, "rootPath">) {
const { data } = await apiClient.put(`/deployments/${id}`, payload);
return data as DeploymentProject;
}
export async function deleteDeployment(id: string): Promise<void> {
await apiClient.delete(`/deployments/${id}`);
}
export async function runDeployment(id: string): Promise<void> {
await apiClient.post(`/deployments/${id}/run`);
}
export async function fetchDeployment(id: string): Promise<DeploymentDetailResponse> {
const { data } = await apiClient.get(`/deployments/${id}`);
return data as DeploymentDetailResponse;
}
export async function fetchDeploymentMetrics(): Promise<DeploymentMetrics> {
const { data } = await apiClient.get("/deployments/metrics/summary");
return data as DeploymentMetrics;
}

View File

@@ -0,0 +1,22 @@
import { apiClient } from "./client";
export interface SettingsResponse {
webhookToken: string;
webhookSecret: string;
updatedAt: string;
}
export async function fetchSettings(): Promise<SettingsResponse> {
const { data } = await apiClient.get("/settings");
return data as SettingsResponse;
}
export async function rotateWebhookToken(): Promise<SettingsResponse> {
const { data } = await apiClient.post("/settings/token/rotate");
return data as SettingsResponse;
}
export async function rotateWebhookSecret(): Promise<SettingsResponse> {
const { data } = await apiClient.post("/settings/secret/rotate");
return data as SettingsResponse;
}

View File

@@ -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 }
],
[]
);

View File

@@ -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<DeploymentProject | null>(null);
const [runs, setRuns] = useState<DeploymentRun[]>([]);
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 (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
Deployment yükleniyor...
</div>
);
}
if (!project) {
return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
Deployment bulunamadı.
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate("/deployments")}>
<FontAwesomeIcon icon={faArrowLeft} />
</Button>
<div>
<h2 className="text-xl font-semibold text-foreground">{project.name}</h2>
<div className="text-sm text-muted-foreground">{project.rootPath}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => navigate("/deployments", { state: { editDeploymentId: project._id } })}
>
Düzenle
</Button>
<Button onClick={handleRun} disabled={triggering} className="gap-2">
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
{triggering ? "Deploying..." : "Deploy"}
</Button>
</div>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Genel Bilgiler</CardTitle>
<JobStatusBadge status={project.lastStatus} />
</CardHeader>
<CardContent className="grid gap-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center gap-3">
<span className="font-medium text-foreground">Repo:</span>
<span className="text-foreground/80">{project.repoUrl}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className="font-medium text-foreground">Branch:</span>
<span className="text-foreground/80">{project.branch}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className="font-medium text-foreground">Compose:</span>
<span className="text-foreground/80">{project.composeFile}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className="font-medium text-foreground">Env:</span>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
{project.env.toUpperCase()}
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className="font-medium text-foreground">Last Deploy:</span>
<span className="text-foreground/80">
{project.lastDeployAt ? new Date(project.lastDeployAt).toLocaleString() : "-"}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Webhook URL</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-3 rounded-md bg-muted px-3 py-2 text-sm">
<code className="break-all text-foreground/80">{webhookUrl}</code>
<Button variant="ghost" size="icon" onClick={handleCopy} title="Kopyala">
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faHistory} className="h-4 w-4" />
Deploy Geçmişi
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{runs.length === 0 && (
<div className="text-sm text-muted-foreground">Henüz deploy çalıştırılmadı.</div>
)}
{runs.map((run) => (
<div
key={run._id}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<div className="flex items-center gap-3">
<JobStatusBadge status={run.status} />
<span className="text-muted-foreground">
{new Date(run.startedAt).toLocaleString()}
</span>
{run.message && (
<span className="truncate text-foreground/80">· {run.message}</span>
)}
</div>
<div className="text-muted-foreground">
{run.durationMs ? `${Math.round(run.durationMs / 1000)}s` : "-"}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Son Deploy Logları</CardTitle>
</CardHeader>
<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">
{latestRun?.logs?.length ? (
latestRun.logs.map((line, idx) => (
<div key={idx} className="whitespace-pre-wrap">
{decorateLogLine(line)}
</div>
))
) : (
<div className="text-muted-foreground">Henüz log yok.</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<DeploymentProject[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [scanning, setScanning] = useState(false);
const [candidates, setCandidates] = useState<DeploymentCandidate[]>([]);
const [form, setForm] = useState<FormState>(defaultForm);
const [pendingEditId, setPendingEditId] = useState<string | null>(null);
const [branchOptions, setBranchOptions] = useState<string[]>([]);
const [branchLoading, setBranchLoading] = useState(false);
const [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({});
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 (
<>
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-foreground">Deployments</h2>
</div>
<Button onClick={handleOpenNew} className="gap-2">
<FontAwesomeIcon icon={faPlus} className="h-4 w-4" />
New Deployment
</Button>
</div>
<div className="grid gap-4">
{loading && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Deployments yükleniyor...
</div>
)}
{!loading && deployments.length === 0 && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
Henüz deployment eklenmemiş.
</div>
)}
{deployments.map((deployment) => {
const faviconUrl = apiBase
? `${apiBase}/deployments/${deployment._id}/favicon`
: `/api/deployments/${deployment._id}/favicon`;
return (
<Card
key={deployment._id}
className="cursor-pointer transition hover:border-primary/50"
onClick={() => navigate(`/deployments/${deployment._id}`)}
>
<CardContent className="p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-primary">
{!faviconErrors[deployment._id] ? (
<img
src={faviconUrl}
alt={`${deployment.name} favicon`}
className="h-4 w-4"
onError={() =>
setFaviconErrors((prev) => ({ ...prev, [deployment._id]: true }))
}
/>
) : (
<FontAwesomeIcon icon={faRocket} className="h-4 w-4" />
)}
</div>
<div>
<div className="text-base font-semibold text-foreground">{deployment.name}</div>
<div className="text-sm text-muted-foreground">{deployment.rootPath}</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<JobStatusBadge status={deployment.lastStatus} />
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
{deployment.env.toUpperCase()}
</span>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
{deployment.composeFile}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleRun(deployment._id);
}}
title="Deploy tetikle"
>
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleEdit(deployment);
}}
title="Düzenle"
>
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDelete(deployment);
}}
title="Sil"
>
</Button>
</div>
</div>
<div className="mt-4 grid gap-1 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">Repo:</span>
<span className="truncate text-foreground/80">{deployment.repoUrl}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">Branch:</span>
<span className="text-foreground/80">{deployment.branch}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">Last Deploy:</span>
<span className="text-foreground/80">{formatDate(deployment.lastDeployAt)}</span>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-border bg-card card-shadow">
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="space-y-1">
<div className="text-lg font-semibold text-foreground">
{isEdit ? "Deployment Güncelle" : "Yeni Deployment"}
</div>
<div className="text-sm text-muted-foreground">
Repo ve branch seçimi sonrası webhook tetiklemeleriyle deploy yapılır.
</div>
</div>
<Button variant="ghost" size="icon" onClick={handleClose}>
</Button>
</div>
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4">
{!isEdit && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Proje Klasörü</Label>
<Button
variant="outline"
size="sm"
onClick={loadCandidates}
disabled={scanning}
>
{scanning ? "Taranıyor..." : "Yeniden Tara"}
</Button>
</div>
<Select
value={form.rootPath}
onValueChange={(value) => {
const candidate = candidates.find((c) => c.rootPath === value);
setForm((prev) => ({
...prev,
rootPath: value,
name: candidate?.name || prev.name,
composeFile: candidate?.composeFiles[0] || prev.composeFile
}));
}}
>
<SelectTrigger>
<SelectValue placeholder="Root altında proje seçin" />
</SelectTrigger>
<SelectContent>
{candidates.map((candidate) => (
<SelectItem key={candidate.rootPath} value={candidate.rootPath}>
{candidate.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
{scanning
? "Root dizin taranıyor..."
: candidates.length === 0
? "Root altında compose dosyası bulunan proje yok."
: "Compose dosyası bulunan klasörleri listeler."}
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="repo">Repo URL</Label>
<Input
id="repo"
value={form.repoUrl}
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
placeholder="https://gitea.example.com/org/repo"
required
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Deployment Name</Label>
<Input
id="name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="wisecolt-app"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="branch">Branch</Label>
{branchOptions.length > 0 ? (
<Select
value={form.branch}
onValueChange={(value) => setForm((prev) => ({ ...prev, branch: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Branch seçin" />
</SelectTrigger>
<SelectContent>
{branchOptions.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="branch"
value={form.branch}
onChange={(e) => setForm((prev) => ({ ...prev, branch: e.target.value }))}
placeholder="main"
required
/>
)}
<div className="text-xs text-muted-foreground">
{branchLoading
? "Branch listesi alınıyor..."
: branchOptions.length > 0
? "Repo üzerindeki branch'lar listelendi."
: "Repo URL girildiğinde branch listesi otomatik gelir."}
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Compose Dosyası</Label>
<Select
value={form.composeFile}
onValueChange={(value) =>
setForm((prev) => ({ ...prev, composeFile: value as DeploymentInput["composeFile"] }))
}
>
<SelectTrigger>
<SelectValue placeholder="Compose seçin" />
</SelectTrigger>
<SelectContent>
{(selectedCandidate?.composeFiles || ["docker-compose.yml", "docker-compose.dev.yml"]).map(
(file) => (
<SelectItem key={file} value={file}>
{file}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="port">Port (opsiyonel)</Label>
<Input
id="port"
type="number"
min={1}
value={form.port}
onChange={(e) => setForm((prev) => ({ ...prev, port: e.target.value }))}
placeholder="3000"
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
<Button variant="ghost" onClick={handleClose} disabled={saving}>
İptal
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Kaydediliyor..." : "Kaydet"}
</Button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -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<JobMetrics | null>(null);
const [deploymentMetrics, setDeploymentMetrics] = useState<DeploymentMetrics | null>(null);
const [deployRuns, setDeployRuns] = useState<DeploymentRunWithProject[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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
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
}));
}, [metrics]);
}
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() {
<CardHeader className="flex items-center justify-between">
<div>
<CardTitle>Son 7 Gün Çalıştırma Trendleri</CardTitle>
<CardDescription>Başarılı / Hatalı job sayıları</CardDescription>
<CardDescription>Test ve Deploy sonuçları</CardDescription>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<FontAwesomeIcon icon={faClockRotateLeft} className="h-3.5 w-3.5" />
{metrics?.totals.totalRuns ?? 0} toplam koşu
</div>
</CardHeader>
<CardContent className="h-80 min-w-0">
<CardContent className="h-48 min-w-0">
{loading ? (
<div className="text-sm text-muted-foreground">Yükleniyor...</div>
) : chartData.length === 0 ? (
@@ -96,10 +177,24 @@ export function HomePage() {
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="date" />
<YAxis allowDecimals={false} />
<Tooltip />
<Tooltip
wrapperStyle={{ zIndex: 50 }}
contentStyle={{
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
boxShadow: "0 10px 20px rgba(0,0,0,0.12)",
color: "#111827",
opacity: 1
}}
labelStyle={{ color: "#111827", fontWeight: 600 }}
itemStyle={{ color: "#111827" }}
/>
<Legend />
<Line type="monotone" dataKey="Başarılı" stroke="#10b981" strokeWidth={2} />
<Line type="monotone" dataKey="Hatalı" stroke="#ef4444" strokeWidth={2} />
<Line type="monotone" dataKey="Test Başarılı" stroke="#10b981" strokeWidth={2} />
<Line type="monotone" dataKey="Test Hatalı" stroke="#ef4444" strokeWidth={2} />
<Line type="monotone" dataKey="Deploy Başarılı" stroke="#f59e0b" strokeWidth={2} />
<Line type="monotone" dataKey="Deploy Hatalı" stroke="#f97316" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
)}
@@ -111,7 +206,7 @@ export function HomePage() {
<CardTitle>Hızlı Metrikler</CardTitle>
<CardDescription>Özet görünüm</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<CardContent className="flex h-48 flex-col justify-center space-y-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Başarı Oranı</span>
<span className="text-lg font-semibold text-foreground">
@@ -136,33 +231,50 @@ export function HomePage() {
<CardHeader className="flex items-center justify-between">
<div>
<CardTitle>Etkinlik Akışı</CardTitle>
<CardDescription>Son 10 job çalıştırması</CardDescription>
<CardDescription>Son 10 aktivite</CardDescription>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<FontAwesomeIcon icon={faListCheck} className="h-3.5 w-3.5" />
{mergedRuns.length ?? 0} kayıt
{activityItems.length ?? 0} kayıt
</div>
</CardHeader>
<CardContent className="space-y-3">
{loading && <div className="text-sm text-muted-foreground">Yükleniyor...</div>}
{error && <div className="text-sm text-destructive">{error}</div>}
{!loading && mergedRuns.length === 0 && (
{!loading && activityItems.length === 0 && (
<div className="text-sm text-muted-foreground">Henüz çalıştırma yok.</div>
)}
{!loading &&
mergedRuns.map((run) => (
activityItems.map((run) => (
<button
key={run._id}
key={run.id}
type="button"
onClick={() => navigate(`/jobs/${run.job._id}`)}
onClick={() => navigate(run.link)}
className="flex w-full items-center justify-between rounded-md border border-border bg-muted/40 px-3 py-2 text-left transition hover:bg-muted"
>
<div className="flex items-center gap-3">
<RepoIcon repoUrl={run.job.repoUrl} />
<RepoIcon repoUrl={run.repoUrl} />
<div>
<div className="text-sm font-semibold text-foreground">{run.job.name}</div>
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${
run.type === "test"
? "border-sky-200 bg-sky-100 text-sky-700"
: "border-amber-200 bg-amber-100 text-amber-800"
}`}
>
<FontAwesomeIcon
icon={run.type === "test" ? faFlaskVial : faRocket}
className="h-3 w-3"
/>
{run.type === "test" ? "Test" : "Deploy"}
</span>
<span>{run.title}</span>
</div>
<div className="text-xs text-muted-foreground">
{new Date(run.startedAt).toLocaleString()} · Süre: {formatDuration(run.durationMs)}
{new Date(run.startedAt).toLocaleString()} · Süre:{" "}
{formatDuration(run.durationMs)}
{run.type === "deploy" && run.message ? ` · ${run.message}` : ""}
</div>
</div>
</div>

View File

@@ -0,0 +1,165 @@
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy, faEye, faEyeSlash, faRotate } 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 { fetchSettings, rotateWebhookSecret, rotateWebhookToken, SettingsResponse } from "../api/settings";
export function SettingsPage() {
const [settings, setSettings] = useState<SettingsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [rotatingToken, setRotatingToken] = useState(false);
const [rotatingSecret, setRotatingSecret] = useState(false);
const [showToken, setShowToken] = useState(false);
const [showSecret, setShowSecret] = useState(false);
useEffect(() => {
fetchSettings()
.then((data) => setSettings(data))
.catch(() => toast.error("Settings yüklenemedi"))
.finally(() => setLoading(false));
}, []);
const handleCopy = async (value: string, label: string) => {
try {
await navigator.clipboard.writeText(value);
toast.success(`${label} kopyalandı`);
} catch {
toast.error(`${label} kopyalanamadı`);
}
};
const handleRotateToken = async () => {
const ok = window.confirm(
"API Token yenilenecek. Gitea webhook ayarları güncellenmezse mevcut deployment'lar tetiklenmez. Devam etmek istiyor musun?"
);
if (!ok) return;
setRotatingToken(true);
try {
const data = await rotateWebhookToken();
setSettings((prev) => (prev ? { ...prev, webhookToken: data.webhookToken } : data));
toast.success("API token yenilendi");
} catch {
toast.error("API token yenilenemedi");
} finally {
setRotatingToken(false);
}
};
const handleRotateSecret = async () => {
const ok = window.confirm(
"Webhook Secret yenilenecek. Gitea webhook ayarları güncellenmezse imza doğrulaması başarısız olur. Devam etmek istiyor musun?"
);
if (!ok) return;
setRotatingSecret(true);
try {
const data = await rotateWebhookSecret();
setSettings((prev) => (prev ? { ...prev, webhookSecret: data.webhookSecret } : data));
toast.success("Webhook secret yenilendi");
} catch {
toast.error("Webhook secret yenilenemedi");
} finally {
setRotatingSecret(false);
}
};
if (loading) {
return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
Settings yükleniyor...
</div>
);
}
if (!settings) {
return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
Settings bulunamadı.
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-foreground">Settings</h2>
<p className="text-sm text-muted-foreground">
Gitea webhook çağrıları için API token ve secret bilgileri.
</p>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>API Token</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRotateToken} disabled={rotatingToken}>
<FontAwesomeIcon icon={faRotate} className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-3 rounded-md bg-muted px-3 py-2 text-sm">
<code className="break-all text-foreground/80">
{showToken ? settings.webhookToken : "•".repeat(settings.webhookToken.length)}
</code>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowToken((prev) => !prev)}
title={showToken ? "Gizle" : "Göster"}
>
<FontAwesomeIcon icon={showToken ? faEyeSlash : faEye} className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(settings.webhookToken, "API token")}
title="Kopyala"
>
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Webhook Secret</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRotateSecret} disabled={rotatingSecret}>
<FontAwesomeIcon icon={faRotate} className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-3 rounded-md bg-muted px-3 py-2 text-sm">
<code className="break-all text-foreground/80">
{showSecret ? settings.webhookSecret : "•".repeat(settings.webhookSecret.length)}
</code>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowSecret((prev) => !prev)}
title={showSecret ? "Gizle" : "Göster"}
>
<FontAwesomeIcon icon={showSecret ? faEyeSlash : faEye} className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(settings.webhookSecret, "Webhook secret")}
title="Kopyala"
>
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}