Compare commits

..

14 Commits

Author SHA1 Message Date
b04ac03739 feat(deployments): deployment restart özelliği ekle
Deployment projeleri için yeniden başlatma (restart) yeteneği eklendi.
Backend servisi, API endpoint'i ve kullanıcı arayüzü butonları güncellendi.
2026-02-03 08:53:03 +00:00
a117275efe feat(ui): metrikleri odaklanma ve gezinme durumunda yenile
Metriklerin güncel kalması için pencere odaklanıldığında ve sayfa
gezinildiğinde verilerin yeniden yüklenmesi eklendi. Backend deployment
servisinde tip tanımları güncellendi.
2026-02-02 19:03:41 +00:00
003ddfcbd1 feat(backend): dosya sistemi tabanlı veri kalıcılığı ekle
Deployment ve job verilerinin dosya sisteminde JSON formatında saklanması
ve uygulama başladığında bu verilerin otomatik olarak yüklenmesi özelliği
eklendi.

- Deployment ve job metadata'ları dosya sisteminde saklanır
- Run geçmişi dosya sisteminde JSON olarak tutulur
- Uygulama başlangıcında dosya sistemi taranır ve eksik veriler yüklenir
- Git'ten repo URL ve branch bilgileri çıkarılabilir
- Commit mesajları normalize edilir
- Ayarlar (webhook token/secret) dosya sisteminde saklanır
2026-01-31 07:17:27 +00:00
535b5cbdc2 fix(auth): kimlik doğrulama hatasında durumu temizle
Kullanıcı verisi getirme başarısız olduğunda artık tüm kimlik doğrulama
durumunu (token, kullanıcı bilgileri) temizler, böylece eski oturum
bilgileri kalıcı olmaz.
2026-01-26 15:34:12 +00:00
2ff3fb6ee6 feat(deployments): düzenleme modalı ve deploy mesajı desteği ekle
Deployment detay sayfasında düzenleme modalı eklendi. Repo URL, branch,
compose dosyası ve environment değişkenleri inline düzenlenebilir hale
getirildi. Deploy tetikleme işlemi için özel mesaj parametresi desteği
eklendi. Düzenleme sonrası otomatik deploy tetikleme özelliği aktif edildi.
2026-01-19 17:08:50 +03:00
0092c28571 fix(ui): deployment modal layout düzenle
Modal ve sekmeler için sabit yükseklikler eklenerek
layout tutarlılığı sağlandı ve taşma sorunları giderildi.
2026-01-19 16:48:11 +03:00
fd020bd9d8 feat(deployments): environment variable desteği ekle
Deployment projelerine environment variable konfigürasyonu eklendi.
Backend tarafında DeploymentProject modeline envContent ve envExampleName
alanları eklendi. Repo içindeki .env.example dosyalarını listelemek için
yeni bir endpoint eklendi. Deployment sürecinde belirlenen env içeriği
.proje dizinine .env dosyası olarak yazılıyor.

Frontend tarafında deployment formuna "Genel" ve "Environment" sekmeleri
eklendi. Remote repodan .env.example dosyaları çekilebiliyor ve içerik
düzenlenebiliyor. Env içeriği için göster/gizle toggle'ı eklendi.
2026-01-19 15:46:22 +03:00
e7a5690d98 feat(deployments): anlık durum ve log izleme özelliği ekle
- Socket.IO tabanlı gerçek zamanlı deployment log ve durum bildirimleri ekle
- deployment:subscribe ve deployment:unsubscribe soket olaylarını destekle
- DeploymentService'e anlık durum ve log yayınlama özelliği ekle
- Deployment silinirken docker kaynaklarını temizle
- Ortam değişkenlerini tek bir .env.example dosyasında birleştir
- Docker compose yapılandırmasını güncelle (PWD ve DEPLOYMENTS_ROOT kullan)
- Repo URL'sinden proje adını otomatik öner
- Güvensiz bağlamlar için clipboard kopya fallback mekanizması ekle
- Socket.IO path'ini /api/socket.io olarak ayarla
2026-01-19 15:11:45 +03:00
a87baa653a Merge pull request 'fix(deployments): deployment kök yolunu sabitle' (#6) from deployment-dev into master
Reviewed-on: #6
2026-01-19 10:31:48 +00:00
a40d07917b Merge pull request 'feat(deployments): repo tabanlı kurulum sistemi ekle ve root taramayı kaldır' (#5) from deployment-dev into master
Reviewed-on: #5
2026-01-19 09:54:54 +00:00
b6f6dcdff7 Merge pull request 'feat(ui): birleşik metrik hesaplaması ekle' (#4) from deployment-dev into master
Reviewed-on: #4
2026-01-18 14:28:49 +00:00
2393078933 Merge pull request 'docs(env): MongoDB bağlantı örneğini güncelle' (#3) from deployment-dev into master
Reviewed-on: #3
2026-01-18 14:17:01 +00:00
2ad6431a28 Merge pull request 'refactor(ui,docs): Job terimini Test olarak güncelle' (#2) from deployment-dev into master
Reviewed-on: #2
2026-01-18 13:42:59 +00:00
2b053120cb Projeleri otomatik deployment etme özelliği eklendi.
Reviewed-on: #1
2026-01-18 13:40:52 +00:00
24 changed files with 1880 additions and 177 deletions

View File

@@ -1,3 +1,14 @@
# Backend Environment
PORT=4000
MONGO_URI=mongodb://mongo:27017/wisecoltci
ADMIN_USERNAME=admin
ADMIN_PASSWORD=supersecret
JWT_SECRET=change-me
CLIENT_ORIGIN=http://localhost:5173
# Frontend Environment
VITE_API_URL=http://localhost:4000/api
# ---------------------------------- CLAUDE API SETTINGS ---------------------------------- # # ---------------------------------- CLAUDE API SETTINGS ---------------------------------- #
# === Claude API Config === # === Claude API Config ===
API_KEY_LITE="your-lite-key" API_KEY_LITE="your-lite-key"
@@ -7,6 +18,3 @@ ACTIVE_KEY=lite
# === Anthropic API Settings === # === Anthropic API Settings ===
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic" ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
ANTHROPIC_MODEL="glm-4.7" ANTHROPIC_MODEL="glm-4.7"
# Host üzerinde projelerin bulunduğu dizin (compose volume için, zorunludur)
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ dist
.DS_Store .DS_Store
test-runs test-runs
backend/test-runs backend/test-runs
deployments/

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ export interface DeploymentProjectDocument extends Document {
webhookToken: string; webhookToken: string;
env: DeploymentEnv; env: DeploymentEnv;
port?: number; port?: number;
envContent?: string;
envExampleName?: string;
lastDeployAt?: Date; lastDeployAt?: Date;
lastStatus: DeploymentStatus; lastStatus: DeploymentStatus;
lastMessage?: string; lastMessage?: string;
@@ -34,6 +36,8 @@ const DeploymentProjectSchema = new Schema<DeploymentProjectDocument>(
webhookToken: { type: String, required: true, unique: true, index: true }, webhookToken: { type: String, required: true, unique: true, index: true },
env: { type: String, required: true, enum: ["dev", "prod"] }, env: { type: String, required: true, enum: ["dev", "prod"] },
port: { type: Number }, port: { type: Number },
envContent: { type: String },
envExampleName: { type: String },
lastDeployAt: { type: Date }, lastDeployAt: { type: Date },
lastStatus: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" }, lastStatus: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" },
lastMessage: { type: String } lastMessage: { type: String }

View File

@@ -1,10 +1,10 @@
import { Router } from "express"; import { Router } from "express";
import fs from "fs";
import path from "path"; import path from "path";
import { authMiddleware } from "../middleware/authMiddleware.js"; import { authMiddleware } from "../middleware/authMiddleware.js";
import { deploymentService } from "../services/deploymentService.js"; import { deploymentService } from "../services/deploymentService.js";
import { DeploymentProject } from "../models/deploymentProject.js"; import { DeploymentProject } from "../models/deploymentProject.js";
import { DeploymentRun } from "../models/deploymentRun.js"; import { DeploymentRun } from "../models/deploymentRun.js";
import fs from "fs";
const router = Router(); const router = Router();
@@ -70,8 +70,28 @@ router.get("/compose-files", async (req, res) => {
}); });
}); });
router.get("/env-examples", async (req, res) => {
authMiddleware(req, res, async () => {
const repoUrl = req.query.repoUrl as string | undefined;
const branch = req.query.branch as string | undefined;
if (!repoUrl || !branch) {
return res.status(400).json({ message: "repoUrl ve branch gerekli" });
}
try {
const examples = await deploymentService.listRemoteEnvExamples(repoUrl, branch);
return res.json({ examples });
} catch (err) {
return res.status(400).json({ message: "Env example alınamadı", error: (err as Error).message });
}
});
});
router.get("/metrics/summary", async (req, res) => { router.get("/metrics/summary", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const deploymentCount = await DeploymentProject.countDocuments();
if (deploymentCount === 0) {
await deploymentService.bootstrapFromFilesystem();
}
const since = new Date(); const since = new Date();
since.setDate(since.getDate() - 7); since.setDate(since.getDate() - 7);
@@ -108,7 +128,11 @@ router.get("/metrics/summary", async (req, res) => {
router.get("/", async (_req, res) => { router.get("/", async (_req, res) => {
authMiddleware(_req, res, async () => { authMiddleware(_req, res, async () => {
const projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean(); let projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean();
if (projects.length === 0) {
await deploymentService.bootstrapFromFilesystem();
projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean();
}
return res.json(projects); return res.json(projects);
}); });
}); });
@@ -128,7 +152,7 @@ router.get("/:id", async (req, res) => {
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const { name, repoUrl, branch, composeFile, port } = req.body; const { name, repoUrl, branch, composeFile, port, envContent, envExampleName } = req.body;
if (!name || !repoUrl || !branch || !composeFile) { if (!name || !repoUrl || !branch || !composeFile) {
return res.status(400).json({ message: "Tüm alanlar gerekli" }); return res.status(400).json({ message: "Tüm alanlar gerekli" });
} }
@@ -138,8 +162,13 @@ router.post("/", async (req, res) => {
repoUrl, repoUrl,
branch, branch,
composeFile, composeFile,
port port,
envContent,
envExampleName
}); });
deploymentService
.runDeployment(created._id.toString(), { message: "First deployment" })
.catch(() => undefined);
return res.status(201).json(created); return res.status(201).json(created);
} catch (err) { } catch (err) {
return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message }); return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message });
@@ -150,7 +179,7 @@ router.post("/", async (req, res) => {
router.put("/:id", async (req, res) => { router.put("/:id", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const { id } = req.params; const { id } = req.params;
const { name, repoUrl, branch, composeFile, port } = req.body; const { name, repoUrl, branch, composeFile, port, envContent, envExampleName } = req.body;
if (!name || !repoUrl || !branch || !composeFile) { if (!name || !repoUrl || !branch || !composeFile) {
return res.status(400).json({ message: "Tüm alanlar gerekli" }); return res.status(400).json({ message: "Tüm alanlar gerekli" });
} }
@@ -160,7 +189,9 @@ router.put("/:id", async (req, res) => {
repoUrl, repoUrl,
branch, branch,
composeFile, composeFile,
port port,
envContent,
envExampleName
}); });
if (!updated) return res.status(404).json({ message: "Deployment bulunamadı" }); if (!updated) return res.status(404).json({ message: "Deployment bulunamadı" });
return res.json(updated); return res.json(updated);
@@ -174,9 +205,12 @@ router.delete("/:id", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const { id } = req.params; const { id } = req.params;
try { try {
const deleted = await DeploymentProject.findByIdAndDelete(id); const project = await DeploymentProject.findById(id);
if (!deleted) return res.status(404).json({ message: "Deployment bulunamadı" }); if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
await deploymentService.cleanupProjectResources(project);
await DeploymentProject.findByIdAndDelete(id);
await DeploymentRun.deleteMany({ project: id }); await DeploymentRun.deleteMany({ project: id });
await fs.promises.rm(project.rootPath, { recursive: true, force: true });
return res.json({ success: true }); return res.json({ success: true });
} catch (err) { } catch (err) {
return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message }); return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message });
@@ -189,7 +223,25 @@ router.post("/:id/run", async (req, res) => {
const { id } = req.params; const { id } = req.params;
const project = await DeploymentProject.findById(id); const project = await DeploymentProject.findById(id);
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" }); if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
deploymentService.runDeployment(id).catch(() => undefined); const rawMessage = typeof req.body?.message === "string" ? req.body.message.trim() : "";
const message = rawMessage || "manual deploy trigger";
deploymentService
.runDeployment(id, { message })
.catch(() => undefined);
return res.json({ queued: true });
});
});
router.post("/:id/restart", 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ı" });
const rawMessage = typeof req.body?.message === "string" ? req.body.message.trim() : "";
const message = rawMessage || "restart";
deploymentService
.restartDeployment(id, { message })
.catch(() => undefined);
return res.json({ queued: true }); return res.json({ queued: true });
}); });
}); });

View File

@@ -9,7 +9,11 @@ const router = Router();
router.use(authMiddleware); router.use(authMiddleware);
router.get("/", async (_req, res) => { router.get("/", async (_req, res) => {
const jobs = await Job.find().sort({ createdAt: -1 }).lean(); let jobs = await Job.find().sort({ createdAt: -1 }).lean();
if (jobs.length === 0) {
await jobService.bootstrapFromFilesystem();
jobs = await Job.find().sort({ createdAt: -1 }).lean();
}
const counts = await JobRun.aggregate([ const counts = await JobRun.aggregate([
{ $group: { _id: "$job", runCount: { $sum: 1 } } } { $group: { _id: "$job", runCount: { $sum: 1 } } }
]); ]);
@@ -26,6 +30,10 @@ router.get("/", async (_req, res) => {
}); });
router.get("/metrics/summary", async (_req, res) => { router.get("/metrics/summary", async (_req, res) => {
const jobCount = await Job.countDocuments();
if (jobCount === 0) {
await jobService.bootstrapFromFilesystem();
}
const since = new Date(); const since = new Date();
since.setDate(since.getDate() - 7); since.setDate(since.getDate() - 7);
@@ -87,6 +95,7 @@ router.post("/", async (req, res) => {
} }
try { try {
const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit }); const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit });
await jobService.persistMetadata(job);
jobService.scheduleJob(job); jobService.scheduleJob(job);
// Yeni job oluşturulduğunda ilk test otomatik tetiklensin // Yeni job oluşturulduğunda ilk test otomatik tetiklensin
jobService.runJob(job._id.toString()).catch(() => undefined); jobService.runJob(job._id.toString()).catch(() => undefined);
@@ -106,6 +115,7 @@ router.put("/:id", async (req, res) => {
{ new: true, runValidators: true } { new: true, runValidators: true }
); );
if (!job) return res.status(404).json({ message: "Job bulunamadı" }); if (!job) return res.status(404).json({ message: "Job bulunamadı" });
await jobService.persistMetadata(job);
jobService.scheduleJob(job); jobService.scheduleJob(job);
return res.json(job); return res.json(job);
} catch (err) { } catch (err) {

View File

@@ -1,6 +1,6 @@
import { Router, Request } from "express"; import { Router, Request } from "express";
import crypto from "crypto"; import crypto from "crypto";
import { deploymentService } from "../services/deploymentService.js"; import { deploymentService, normalizeCommitMessage } from "../services/deploymentService.js";
const router = Router(); const router = Router();
@@ -18,6 +18,12 @@ function verifySignature(rawBody: Buffer, secret: string, signature: string) {
return crypto.timingSafeEqual(Buffer.from(cleaned), Buffer.from(expected)); return crypto.timingSafeEqual(Buffer.from(cleaned), Buffer.from(expected));
} }
function normalizeBranch(value: string | undefined) {
const raw = (value || "").trim();
if (!raw) return "";
return raw.startsWith("refs/heads/") ? raw.replace("refs/heads/", "") : raw;
}
router.post("/api/deployments/webhook/:token", async (req, res) => { router.post("/api/deployments/webhook/:token", async (req, res) => {
const { token } = req.params; const { token } = req.params;
const settings = await deploymentService.ensureSettings(); const settings = await deploymentService.ensureSettings();
@@ -46,14 +52,16 @@ router.post("/api/deployments/webhook/:token", async (req, res) => {
const payload = req.body as { ref?: string; head_commit?: { message?: string }; commits?: Array<{ message?: string }> }; const payload = req.body as { ref?: string; head_commit?: { message?: string }; commits?: Array<{ message?: string }> };
const ref = payload?.ref || ""; const ref = payload?.ref || "";
const branch = ref.startsWith("refs/heads/") ? ref.replace("refs/heads/", "") : ref; const branch = normalizeBranch(ref);
const commitMessage = const commitMessageRaw =
payload?.head_commit?.message || payload?.commits?.[payload.commits.length - 1]?.message; payload?.head_commit?.message || payload?.commits?.[payload.commits.length - 1]?.message;
const commitMessage = normalizeCommitMessage(commitMessageRaw);
const project = await deploymentService.findByWebhookToken(token); const project = await deploymentService.findByWebhookToken(token);
if (!project) return res.status(404).json({ message: "Deployment bulunamadi" }); if (!project) return res.status(404).json({ message: "Deployment bulunamadi" });
if (branch && branch !== project.branch) { const projectBranch = normalizeBranch(project.branch);
if (projectBranch && projectBranch !== "*" && branch && branch !== projectBranch) {
return res.json({ ignored: true }); return res.json({ ignored: true });
} }

View File

@@ -2,18 +2,199 @@ import fs from "fs";
import path from "path"; import path from "path";
import crypto from "crypto"; import crypto from "crypto";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { Server } from "socket.io";
import { config } from "../config/env.js";
import { import {
DeploymentProject, DeploymentProject,
DeploymentProjectDocument, DeploymentProjectDocument,
ComposeFile, ComposeFile,
DeploymentEnv DeploymentEnv
} from "../models/deploymentProject.js"; } from "../models/deploymentProject.js";
import { DeploymentRun } from "../models/deploymentRun.js"; import { DeploymentRun, DeploymentRunDocument } from "../models/deploymentRun.js";
import { Settings } from "../models/settings.js"; import { Settings } from "../models/settings.js";
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"]; const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
const deploymentsRoot = "/workspace/deployments"; const deploymentsRoot = config.deploymentsRoot;
const metadataFileName = ".wisecolt-ci.json";
const settingsFileName = ".wisecolt-ci-settings.json";
const runsDirName = ".wisecolt-ci-runs";
export function normalizeCommitMessage(message?: string) {
if (!message) return undefined;
const firstLine = message.split(/\r?\n/)[0]?.trim();
return firstLine || undefined;
}
type DeploymentMetadata = {
name: string;
repoUrl: string;
branch: string;
composeFile: ComposeFile;
webhookToken: string;
env: DeploymentEnv;
port?: number;
envContent?: string;
envExampleName?: string;
};
type SettingsMetadata = {
webhookToken: string;
webhookSecret: string;
};
type StoredRun = {
status: "running" | "success" | "failed";
message?: string;
logs: string[];
startedAt: string;
finishedAt?: string;
durationMs?: number;
createdAt: string;
updatedAt: string;
};
async function readMetadata(repoDir: string): Promise<DeploymentMetadata | null> {
const filePath = path.join(repoDir, metadataFileName);
if (!fs.existsSync(filePath)) return null;
try {
const raw = await fs.promises.readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as DeploymentMetadata;
if (!parsed?.repoUrl || !parsed?.composeFile) return null;
return parsed;
} catch {
return null;
}
}
async function writeMetadata(repoDir: string, data: DeploymentMetadata) {
const filePath = path.join(repoDir, metadataFileName);
const payload = JSON.stringify(data, null, 2);
await fs.promises.writeFile(filePath, payload, "utf8");
}
function getRunsDir(repoDir: string) {
return path.join(repoDir, runsDirName);
}
function serializeRun(run: DeploymentRunDocument) {
return {
status: run.status,
message: run.message,
logs: run.logs || [],
startedAt: new Date(run.startedAt).toISOString(),
finishedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : undefined,
durationMs: run.durationMs,
createdAt: new Date(run.createdAt).toISOString(),
updatedAt: new Date(run.updatedAt).toISOString()
} satisfies StoredRun;
}
async function writeRunFile(repoDir: string, run: DeploymentRunDocument) {
const dir = getRunsDir(repoDir);
await fs.promises.mkdir(dir, { recursive: true });
const data = serializeRun(run);
const name = `${new Date(data.startedAt).getTime()}-${run._id.toString()}.json`;
const filePath = path.join(dir, name);
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
}
async function readStoredRuns(repoDir: string): Promise<StoredRun[]> {
const dir = getRunsDir(repoDir);
if (!fs.existsSync(dir)) return [];
const entries = await fs.promises.readdir(dir);
const items: StoredRun[] = [];
for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
try {
const raw = await fs.promises.readFile(path.join(dir, entry), "utf8");
const parsed = JSON.parse(raw) as StoredRun;
if (!parsed?.startedAt || !parsed?.status) continue;
items.push(parsed);
} catch {
// ignore invalid file
}
}
return items;
}
async function readSettingsFile(): Promise<SettingsMetadata | null> {
const filePath = path.join(deploymentsRoot, settingsFileName);
if (!fs.existsSync(filePath)) return null;
try {
const raw = await fs.promises.readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as SettingsMetadata;
if (!parsed?.webhookToken || !parsed?.webhookSecret) return null;
return parsed;
} catch {
return null;
}
}
async function writeSettingsFile(data: SettingsMetadata) {
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
const filePath = path.join(deploymentsRoot, settingsFileName);
const payload = JSON.stringify(data, null, 2);
await fs.promises.writeFile(filePath, payload, "utf8");
}
function inferComposeFile(repoDir: string): ComposeFile | null {
const prod = path.join(repoDir, "docker-compose.yml");
if (fs.existsSync(prod)) return "docker-compose.yml";
const dev = path.join(repoDir, "docker-compose.dev.yml");
if (fs.existsSync(dev)) return "docker-compose.dev.yml";
return null;
}
async function inferRepoUrlFromGit(repoDir: string): Promise<string | null> {
const gitConfig = path.join(repoDir, ".git", "config");
if (!fs.existsSync(gitConfig)) return null;
try {
const content = await fs.promises.readFile(gitConfig, "utf8");
const lines = content.split(/\r?\n/);
let inOrigin = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("[remote \"")) {
inOrigin = trimmed === "[remote \"origin\"]";
continue;
}
if (!inOrigin) continue;
if (trimmed.startsWith("url")) {
const parts = trimmed.split("=");
const value = parts.slice(1).join("=").trim();
return value || null;
}
}
return null;
} catch {
return null;
}
}
async function inferBranchFromGit(repoDir: string): Promise<string | null> {
const headPath = path.join(repoDir, ".git", "HEAD");
if (!fs.existsSync(headPath)) return null;
try {
const head = (await fs.promises.readFile(headPath, "utf8")).trim();
if (!head.startsWith("ref:")) return null;
const ref = head.replace("ref:", "").trim();
const prefix = "refs/heads/";
if (ref.startsWith(prefix)) {
return ref.slice(prefix.length);
}
return null;
} catch {
return null;
}
}
function inferName(repoUrl: string, rootPath: string) {
const normalized = repoUrl.replace(/\/+$/, "");
const lastPart = normalized.split("/").pop() || "";
const cleaned = lastPart.replace(/\.git$/i, "");
return cleaned || path.basename(rootPath);
}
function slugify(value: string) { function slugify(value: string) {
return value return value
@@ -118,11 +299,37 @@ async function ensureRepo(project: DeploymentProjectDocument, onData: (line: str
if (!exists) { if (!exists) {
const entries = await fs.promises.readdir(repoDir); const entries = await fs.promises.readdir(repoDir);
if (entries.length > 0) { const allowed = new Set<string>([metadataFileName, ".env", ".env.local", runsDirName]);
const blocking = entries.filter((name) => !allowed.has(name));
if (blocking.length > 0) {
throw new Error("Repo klasoru git olmayan dosyalar iceriyor"); throw new Error("Repo klasoru git olmayan dosyalar iceriyor");
} }
let envBackup: string | null = null;
const envPath = path.join(repoDir, ".env");
if (fs.existsSync(envPath)) {
envBackup = await fs.promises.readFile(envPath, "utf8");
}
await Promise.all(
entries
.filter((name) => allowed.has(name))
.map((name) => fs.promises.rm(path.join(repoDir, name), { force: true }))
);
onData(`Repo klonlanıyor: ${project.repoUrl}`); onData(`Repo klonlanıyor: ${project.repoUrl}`);
await runCommand(`git clone --branch ${project.branch} ${project.repoUrl} .`, repoDir, onData); await runCommand(`git clone --branch ${project.branch} ${project.repoUrl} .`, repoDir, onData);
if (envBackup) {
await fs.promises.writeFile(envPath, envBackup, "utf8");
}
await writeMetadata(repoDir, {
name: project.name,
repoUrl: project.repoUrl,
branch: project.branch,
composeFile: project.composeFile,
webhookToken: project.webhookToken,
env: project.env,
port: project.port,
envContent: project.envContent,
envExampleName: project.envExampleName
});
} else { } else {
onData("Repo güncelleniyor (git fetch/pull)..."); onData("Repo güncelleniyor (git fetch/pull)...");
await runCommand(`git fetch origin ${project.branch}`, repoDir, onData); await runCommand(`git fetch origin ${project.branch}`, repoDir, onData);
@@ -152,6 +359,39 @@ async function runCompose(project: DeploymentProjectDocument, onData: (line: str
class DeploymentService { class DeploymentService {
private running: Map<string, boolean> = new Map(); private running: Map<string, boolean> = new Map();
private io: Server | null = null;
setSocket(io: Server) {
this.io = io;
}
private async emitStatus(deploymentId: string, payload: Partial<DeploymentProjectDocument>) {
if (!this.io) return;
const runCount = await DeploymentRun.countDocuments({ project: deploymentId });
const body = {
deploymentId,
status: payload.lastStatus,
lastRunAt: payload.lastDeployAt,
lastMessage: payload.lastMessage,
runCount
};
this.io.to(`deployment:${deploymentId}`).emit("deployment:status", body);
this.io.emit("deployment:status", body);
}
private emitLog(deploymentId: string, line: string) {
if (!this.io) return;
this.io.to(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
this.io.except(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
}
private emitRun(deploymentId: string, run: DeploymentRunDocument) {
if (!this.io) return;
this.io.to(`deployment:${deploymentId}`).emit("deployment:run", {
deploymentId,
run
});
}
async listRemoteBranches(repoUrl: string) { async listRemoteBranches(repoUrl: string) {
const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd()); const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd());
@@ -183,13 +423,53 @@ class DeploymentService {
} }
} }
async listRemoteEnvExamples(repoUrl: string, branch: string) {
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
const tmpBase = await fs.promises.mkdtemp(path.join(deploymentsRoot, ".tmp-"));
try {
await runCommand(
`git clone --depth 1 --single-branch --branch ${branch} ${repoUrl} ${tmpBase}`,
process.cwd(),
() => undefined
);
const entries = await fs.promises.readdir(tmpBase, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((name) => name.toLowerCase().endsWith(".env.example"));
const items = await Promise.all(
files.map(async (name) => ({
name,
content: await fs.promises.readFile(path.join(tmpBase, name), "utf8")
}))
);
return items;
} finally {
await fs.promises.rm(tmpBase, { recursive: true, force: true });
}
}
async ensureSettings() { async ensureSettings() {
const existing = await Settings.findOne(); const existing = await Settings.findOne();
if (existing) return existing; if (existing) return existing;
const fileSettings = await readSettingsFile();
if (fileSettings) {
const createdFromFile = await Settings.create({
webhookToken: fileSettings.webhookToken,
webhookSecret: fileSettings.webhookSecret
});
return createdFromFile;
}
const created = await Settings.create({ const created = await Settings.create({
webhookToken: generateApiToken(), webhookToken: generateApiToken(),
webhookSecret: generateSecret() webhookSecret: generateSecret()
}); });
await writeSettingsFile({
webhookToken: created.webhookToken,
webhookSecret: created.webhookSecret
});
return created; return created;
} }
@@ -197,6 +477,10 @@ class DeploymentService {
const settings = await this.ensureSettings(); const settings = await this.ensureSettings();
settings.webhookToken = generateApiToken(); settings.webhookToken = generateApiToken();
await settings.save(); await settings.save();
await writeSettingsFile({
webhookToken: settings.webhookToken,
webhookSecret: settings.webhookSecret
});
return settings; return settings;
} }
@@ -204,6 +488,10 @@ class DeploymentService {
const settings = await this.ensureSettings(); const settings = await this.ensureSettings();
settings.webhookSecret = generateSecret(); settings.webhookSecret = generateSecret();
await settings.save(); await settings.save();
await writeSettingsFile({
webhookToken: settings.webhookToken,
webhookSecret: settings.webhookSecret
});
return settings; return settings;
} }
@@ -213,6 +501,8 @@ class DeploymentService {
branch: string; branch: string;
composeFile: ComposeFile; composeFile: ComposeFile;
port?: number; port?: number;
envContent?: string;
envExampleName?: string;
}) { }) {
const repoUrl = normalizeRepoUrl(input.repoUrl); const repoUrl = normalizeRepoUrl(input.repoUrl);
const existingRepo = await DeploymentProject.findOne({ repoUrl }); const existingRepo = await DeploymentProject.findOne({ repoUrl });
@@ -238,7 +528,7 @@ class DeploymentService {
} }
const env = deriveEnv(input.composeFile); const env = deriveEnv(input.composeFile);
return DeploymentProject.create({ const created = await DeploymentProject.create({
name: input.name, name: input.name,
rootPath, rootPath,
repoUrl, repoUrl,
@@ -246,8 +536,22 @@ class DeploymentService {
composeFile: input.composeFile, composeFile: input.composeFile,
webhookToken, webhookToken,
env, env,
port: input.port port: input.port,
envContent: input.envContent,
envExampleName: input.envExampleName
}); });
await writeMetadata(rootPath, {
name: created.name,
repoUrl: created.repoUrl,
branch: created.branch,
composeFile: created.composeFile,
webhookToken: created.webhookToken,
env: created.env,
port: created.port,
envContent: created.envContent,
envExampleName: created.envExampleName
});
return created;
} }
async updateProject( async updateProject(
@@ -258,6 +562,8 @@ class DeploymentService {
branch: string; branch: string;
composeFile: ComposeFile; composeFile: ComposeFile;
port?: number; port?: number;
envContent?: string;
envExampleName?: string;
} }
) { ) {
const project = await DeploymentProject.findById(id); const project = await DeploymentProject.findById(id);
@@ -282,10 +588,25 @@ class DeploymentService {
branch: input.branch, branch: input.branch,
composeFile: input.composeFile, composeFile: input.composeFile,
env, env,
port: input.port port: input.port,
envContent: input.envContent,
envExampleName: input.envExampleName
}, },
{ new: true, runValidators: true } { new: true, runValidators: true }
); );
if (updated) {
await writeMetadata(updated.rootPath, {
name: updated.name,
repoUrl: updated.repoUrl,
branch: updated.branch,
composeFile: updated.composeFile,
webhookToken: updated.webhookToken,
env: updated.env,
port: updated.port,
envContent: updated.envContent,
envExampleName: updated.envExampleName
});
}
return updated; return updated;
} }
@@ -301,41 +622,61 @@ class DeploymentService {
return; return;
} }
const normalizedMessage = normalizeCommitMessage(options?.message);
const startedAt = Date.now(); const startedAt = Date.now();
const runLogs: string[] = []; const runLogs: string[] = [];
const pushLog = (line: string) => { const pushLog = (line: string) => {
runLogs.push(line); runLogs.push(line);
this.emitLog(projectId, line);
}; };
const runDoc = await DeploymentRun.create({ const runDoc = await DeploymentRun.create({
project: projectId, project: projectId,
status: "running", status: "running",
startedAt: new Date(), startedAt: new Date(),
message: options?.message message: normalizedMessage ?? options?.message
}); });
this.emitRun(projectId, runDoc);
await writeRunFile(project.rootPath, runDoc);
await DeploymentProject.findByIdAndUpdate(projectId, { await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "running", lastStatus: "running",
lastMessage: options?.message || "Deploy başlıyor..." lastMessage: normalizedMessage ?? options?.message ?? "Deploy başlıyor..."
}); });
await this.emitStatus(projectId, {
lastStatus: "running",
lastMessage: normalizedMessage ?? options?.message ?? "Deploy başlıyor..."
} as DeploymentProjectDocument);
try { try {
await ensureRepo(project, (line) => pushLog(line)); await ensureRepo(project, (line) => pushLog(line));
if (project.envContent) {
await fs.promises.writeFile(path.join(project.rootPath, ".env"), project.envContent, "utf8");
pushLog(".env güncellendi");
}
pushLog("Deploy komutları çalıştırılıyor..."); pushLog("Deploy komutları çalıştırılıyor...");
await runCompose(project, (line) => pushLog(line)); await runCompose(project, (line) => pushLog(line));
const duration = Date.now() - startedAt; const duration = Date.now() - startedAt;
await DeploymentProject.findByIdAndUpdate(projectId, { await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "success", lastStatus: "success",
lastDeployAt: new Date(), lastDeployAt: new Date(),
lastMessage: options?.message || "Başarılı" lastMessage: normalizedMessage ?? options?.message ?? "Başarılı"
}); });
await this.emitStatus(projectId, {
lastStatus: "success",
lastDeployAt: new Date(),
lastMessage: normalizedMessage ?? options?.message ?? "Başarılı"
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, { await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "success", status: "success",
finishedAt: new Date(), finishedAt: new Date(),
durationMs: duration, durationMs: duration,
logs: runLogs, logs: runLogs,
message: options?.message message: normalizedMessage ?? options?.message
}); });
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
pushLog("Deploy tamamlandı: Başarılı"); pushLog("Deploy tamamlandı: Başarılı");
} catch (err) { } catch (err) {
const duration = Date.now() - startedAt; const duration = Date.now() - startedAt;
@@ -344,22 +685,258 @@ class DeploymentService {
lastDeployAt: new Date(), lastDeployAt: new Date(),
lastMessage: (err as Error).message lastMessage: (err as Error).message
}); });
await this.emitStatus(projectId, {
lastStatus: "failed",
lastDeployAt: new Date(),
lastMessage: (err as Error).message
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, { await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "failed", status: "failed",
finishedAt: new Date(), finishedAt: new Date(),
durationMs: duration, durationMs: duration,
logs: runLogs, logs: runLogs,
message: options?.message message: normalizedMessage ?? options?.message
}); });
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
pushLog(`Hata: ${(err as Error).message}`); pushLog(`Hata: ${(err as Error).message}`);
} finally { } finally {
this.running.delete(projectId); this.running.delete(projectId);
} }
} }
async restartDeployment(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 normalizedMessage = normalizeCommitMessage(options?.message);
const startedAt = Date.now();
const runLogs: string[] = [];
const pushLog = (line: string) => {
runLogs.push(line);
this.emitLog(projectId, line);
};
const runDoc = await DeploymentRun.create({
project: projectId,
status: "running",
startedAt: new Date(),
message: normalizedMessage ?? options?.message
});
this.emitRun(projectId, runDoc);
await writeRunFile(project.rootPath, runDoc);
await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "running",
lastMessage: normalizedMessage ?? options?.message ?? "Restart başlıyor..."
});
await this.emitStatus(projectId, {
lastStatus: "running",
lastMessage: normalizedMessage ?? options?.message ?? "Restart başlıyor..."
} as DeploymentProjectDocument);
try {
pushLog("Restart 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: normalizedMessage ?? options?.message ?? "Restart başarılı"
});
await this.emitStatus(projectId, {
lastStatus: "success",
lastDeployAt: new Date(),
lastMessage: normalizedMessage ?? options?.message ?? "Restart başarılı"
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "success",
finishedAt: new Date(),
durationMs: duration,
logs: runLogs,
message: normalizedMessage ?? options?.message
});
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
pushLog("Restart 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 this.emitStatus(projectId, {
lastStatus: "failed",
lastDeployAt: new Date(),
lastMessage: (err as Error).message
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "failed",
finishedAt: new Date(),
durationMs: duration,
logs: runLogs,
message: normalizedMessage ?? options?.message
});
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
pushLog(`Hata: ${(err as Error).message}`);
} finally {
this.running.delete(projectId);
}
}
async cleanupProjectResources(project: DeploymentProjectDocument) {
const composePath = path.join(project.rootPath, project.composeFile);
if (!fs.existsSync(composePath)) {
return;
}
await runCommand(
`docker compose -f ${project.composeFile} down --remove-orphans -v --rmi local`,
project.rootPath,
() => undefined
);
}
async findByWebhookToken(token: string) { async findByWebhookToken(token: string) {
return DeploymentProject.findOne({ webhookToken: token }); return DeploymentProject.findOne({ webhookToken: token });
} }
async normalizeExistingCommitMessages() {
const projects = await DeploymentProject.find({
lastMessage: { $regex: /[\r\n]/ }
});
for (const project of projects) {
const normalized = normalizeCommitMessage(project.lastMessage);
if (normalized && normalized !== project.lastMessage) {
project.lastMessage = normalized;
await project.save();
}
}
const runs = await DeploymentRun.find({
message: { $regex: /[\r\n]/ }
});
for (const run of runs) {
const normalized = normalizeCommitMessage(run.message);
if (normalized && normalized !== run.message) {
run.message = normalized;
await run.save();
}
}
}
async bootstrapFromFilesystem() {
const candidateRoots = [
deploymentsRoot,
path.resolve(process.cwd(), "deployments"),
path.resolve(process.cwd(), "..", "deployments"),
path.resolve(process.cwd(), "..", "..", "deployments"),
"/root/Wisecolt-CI/deployments"
];
const roots = Array.from(
new Set(candidateRoots.filter((root) => root && fs.existsSync(root)))
);
for (const root of roots) {
const entries = await fs.promises.readdir(root, { withFileTypes: true });
const dirs = entries.filter((entry) => entry.isDirectory());
for (const entry of dirs) {
const rootPath = path.join(root, entry.name);
const existing = await DeploymentProject.findOne({ rootPath });
if (existing) continue;
const metadata = await readMetadata(rootPath);
const repoUrlRaw = metadata?.repoUrl || (await inferRepoUrlFromGit(rootPath));
if (!repoUrlRaw) continue;
const repoUrl = normalizeRepoUrl(repoUrlRaw);
const repoExisting = await DeploymentProject.findOne({ repoUrl });
if (repoExisting) continue;
const composeFile = metadata?.composeFile || inferComposeFile(rootPath);
if (!composeFile) continue;
const branch = metadata?.branch || (await inferBranchFromGit(rootPath)) || "main";
const name = metadata?.name || inferName(repoUrl, rootPath);
let webhookToken = metadata?.webhookToken || generateWebhookToken();
while (await DeploymentProject.findOne({ webhookToken })) {
webhookToken = generateWebhookToken();
}
let envContent = metadata?.envContent;
const envPath = path.join(rootPath, ".env");
if (!envContent && fs.existsSync(envPath)) {
envContent = await fs.promises.readFile(envPath, "utf8");
}
const envExampleName = metadata?.envExampleName;
const env = deriveEnv(composeFile);
const created = await DeploymentProject.create({
name,
rootPath,
repoUrl,
branch,
composeFile,
webhookToken,
env,
port: metadata?.port,
envContent,
envExampleName
});
await writeMetadata(rootPath, {
name: created.name,
repoUrl: created.repoUrl,
branch: created.branch,
composeFile: created.composeFile,
webhookToken: created.webhookToken,
env: created.env,
port: created.port,
envContent: created.envContent,
envExampleName: created.envExampleName
});
const storedRuns = await readStoredRuns(rootPath);
if (storedRuns.length > 0) {
storedRuns.sort(
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
await DeploymentRun.insertMany(
storedRuns.map((run) => ({
project: created._id,
status: run.status,
message: run.message,
logs: run.logs || [],
startedAt: new Date(run.startedAt),
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
durationMs: run.durationMs,
createdAt: new Date(run.createdAt),
updatedAt: new Date(run.updatedAt)
}))
);
const latest = storedRuns[0];
await DeploymentProject.findByIdAndUpdate(created._id, {
lastStatus: latest.status,
lastDeployAt: new Date(latest.finishedAt || latest.startedAt),
lastMessage: latest.message
});
}
}
}
}
} }
export const deploymentService = new DeploymentService(); export const deploymentService = new DeploymentService();

View File

@@ -3,9 +3,11 @@ import path from "path";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { Server } from "socket.io"; import { Server } from "socket.io";
import { Job, JobDocument, TimeUnit } from "../models/job.js"; import { Job, JobDocument, TimeUnit } from "../models/job.js";
import { JobRun } from "../models/jobRun.js"; import { JobRun, JobRunDocument } from "../models/jobRun.js";
const repoBaseDir = path.join(process.cwd(), "test-runs"); const repoBaseDir = path.join(process.cwd(), "test-runs");
const jobMetadataFileName = ".wisecolt-ci-job.json";
const jobRunsDirName = ".wisecolt-ci-job-runs";
function unitToMs(unit: TimeUnit) { function unitToMs(unit: TimeUnit) {
if (unit === "dakika") return 60_000; if (unit === "dakika") return 60_000;
@@ -17,6 +19,91 @@ function ensureDir(dir: string) {
return fs.promises.mkdir(dir, { recursive: true }); return fs.promises.mkdir(dir, { recursive: true });
} }
type JobMetadata = {
name: string;
repoUrl: string;
testCommand: string;
checkValue: number;
checkUnit: TimeUnit;
};
type StoredJobRun = {
status: "running" | "success" | "failed";
logs: string[];
startedAt: string;
finishedAt?: string;
durationMs?: number;
createdAt: string;
updatedAt: string;
};
function getJobDir(jobId: string) {
return path.join(repoBaseDir, jobId);
}
function getJobRunsDir(jobDir: string) {
return path.join(jobDir, jobRunsDirName);
}
async function readJobMetadata(jobDir: string): Promise<JobMetadata | null> {
const filePath = path.join(jobDir, jobMetadataFileName);
if (!fs.existsSync(filePath)) return null;
try {
const raw = await fs.promises.readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as JobMetadata;
if (!parsed?.repoUrl || !parsed?.testCommand) return null;
return parsed;
} catch {
return null;
}
}
async function writeJobMetadata(jobDir: string, data: JobMetadata) {
await ensureDir(jobDir);
const filePath = path.join(jobDir, jobMetadataFileName);
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
}
function serializeJobRun(run: JobRunDocument) {
return {
status: run.status,
logs: run.logs || [],
startedAt: new Date(run.startedAt).toISOString(),
finishedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : undefined,
durationMs: run.durationMs,
createdAt: new Date(run.createdAt).toISOString(),
updatedAt: new Date(run.updatedAt).toISOString()
} satisfies StoredJobRun;
}
async function writeJobRunFile(jobDir: string, run: JobRunDocument) {
const dir = getJobRunsDir(jobDir);
await ensureDir(dir);
const data = serializeJobRun(run);
const name = `${new Date(data.startedAt).getTime()}-${run._id.toString()}.json`;
const filePath = path.join(dir, name);
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
}
async function readStoredJobRuns(jobDir: string): Promise<StoredJobRun[]> {
const dir = getJobRunsDir(jobDir);
if (!fs.existsSync(dir)) return [];
const entries = await fs.promises.readdir(dir);
const items: StoredJobRun[] = [];
for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
try {
const raw = await fs.promises.readFile(path.join(dir, entry), "utf8");
const parsed = JSON.parse(raw) as StoredJobRun;
if (!parsed?.startedAt || !parsed?.status) continue;
items.push(parsed);
} catch {
// ignore invalid file
}
}
return items;
}
function cleanOutput(input: string) { function cleanOutput(input: string) {
// ANSI escape sequences temizleme // ANSI escape sequences temizleme
return input.replace( return input.replace(
@@ -85,8 +172,42 @@ async function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) {
const exists = fs.existsSync(gitDir); const exists = fs.existsSync(gitDir);
if (!exists) { if (!exists) {
const entries = await fs.promises.readdir(repoDir);
const allowed = new Set<string>([jobMetadataFileName, jobRunsDirName]);
const blocking = entries.filter((name) => !allowed.has(name));
if (blocking.length > 0) {
throw new Error("Repo klasoru git olmayan dosyalar iceriyor");
}
let metadataBackup: string | null = null;
const metadataPath = path.join(repoDir, jobMetadataFileName);
if (fs.existsSync(metadataPath)) {
metadataBackup = await fs.promises.readFile(metadataPath, "utf8");
}
let runsBackupPath: string | null = null;
const runsDir = path.join(repoDir, jobRunsDirName);
if (fs.existsSync(runsDir)) {
const tmpBase = await fs.promises.mkdtemp(path.join(repoBaseDir, ".tmp-"));
runsBackupPath = path.join(tmpBase, jobRunsDirName);
await fs.promises.rename(runsDir, runsBackupPath);
}
await Promise.all(
entries
.filter((name) => allowed.has(name))
.map((name) => fs.promises.rm(path.join(repoDir, name), { recursive: true, force: true }))
);
onData(`Repo klonlanıyor: ${job.repoUrl}`); onData(`Repo klonlanıyor: ${job.repoUrl}`);
await runCommand(`git clone ${job.repoUrl} ${repoDir}`, process.cwd(), onData); await runCommand(`git clone ${job.repoUrl} ${repoDir}`, process.cwd(), onData);
if (metadataBackup) {
await fs.promises.writeFile(metadataPath, metadataBackup, "utf8");
}
if (runsBackupPath) {
await fs.promises.rename(runsBackupPath, runsDir);
}
} else { } else {
onData("Repo güncelleniyor (git pull)..."); onData("Repo güncelleniyor (git pull)...");
await runCommand("git pull", repoDir, onData); await runCommand("git pull", repoDir, onData);
@@ -156,6 +277,7 @@ class JobService {
status: "running", status: "running",
startedAt: new Date() startedAt: new Date()
}); });
await writeJobRunFile(getJobDir(jobId), runDoc);
await Job.findByIdAndUpdate(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." }); await Job.findByIdAndUpdate(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." });
await this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument); await this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument);
@@ -179,6 +301,8 @@ class JobService {
durationMs: duration, durationMs: duration,
logs: runLogs logs: runLogs
}); });
const updatedRun = await JobRun.findById(runDoc._id);
if (updatedRun) await writeJobRunFile(getJobDir(jobId), updatedRun);
await this.emitStatus(jobId, { await this.emitStatus(jobId, {
status: "success", status: "success",
lastRunAt: new Date(), lastRunAt: new Date(),
@@ -199,6 +323,8 @@ class JobService {
durationMs: duration, durationMs: duration,
logs: runLogs logs: runLogs
}); });
const updatedRun = await JobRun.findById(runDoc._id);
if (updatedRun) await writeJobRunFile(getJobDir(jobId), updatedRun);
pushLog(`Hata: ${(err as Error).message}`); pushLog(`Hata: ${(err as Error).message}`);
await this.emitStatus(jobId, { await this.emitStatus(jobId, {
status: "failed", status: "failed",
@@ -231,6 +357,78 @@ class JobService {
const jobs = await Job.find(); const jobs = await Job.find();
jobs.forEach((job) => this.scheduleJob(job)); jobs.forEach((job) => this.scheduleJob(job));
} }
async persistMetadata(job: JobDocument) {
await writeJobMetadata(getJobDir(job._id.toString()), {
name: job.name,
repoUrl: job.repoUrl,
testCommand: job.testCommand,
checkValue: job.checkValue,
checkUnit: job.checkUnit
});
}
async bootstrapFromFilesystem() {
const candidateRoots = [
repoBaseDir,
path.resolve(process.cwd(), "test-runs"),
path.resolve(process.cwd(), "..", "test-runs"),
path.resolve(process.cwd(), "..", "..", "test-runs"),
"/root/Wisecolt-CI/test-runs"
];
const roots = Array.from(
new Set(candidateRoots.filter((root) => root && fs.existsSync(root)))
);
for (const root of roots) {
const entries = await fs.promises.readdir(root, { withFileTypes: true });
const dirs = entries.filter((entry) => entry.isDirectory());
for (const entry of dirs) {
const jobDir = path.join(root, entry.name);
const metadata = await readJobMetadata(jobDir);
if (!metadata) continue;
const existing = await Job.findOne({ repoUrl: metadata.repoUrl });
if (existing) continue;
const created = await Job.create({
name: metadata.name,
repoUrl: metadata.repoUrl,
testCommand: metadata.testCommand,
checkValue: metadata.checkValue,
checkUnit: metadata.checkUnit
});
await this.persistMetadata(created);
const storedRuns = await readStoredJobRuns(jobDir);
if (storedRuns.length > 0) {
storedRuns.sort(
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
await JobRun.insertMany(
storedRuns.map((run) => ({
job: created._id,
status: run.status,
logs: run.logs || [],
startedAt: new Date(run.startedAt),
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
durationMs: run.durationMs,
createdAt: new Date(run.createdAt),
updatedAt: new Date(run.updatedAt)
}))
);
const latest = storedRuns[0];
await Job.findByIdAndUpdate(created._id, {
status: latest.status === "running" ? "idle" : latest.status,
lastRunAt: new Date(latest.finishedAt || latest.startedAt),
lastDurationMs: latest.durationMs,
lastMessage: latest.status === "success" ? "Başarılı" : "Hata"
});
}
}
}
}
} }
export const jobService = new JobService(); export const jobService = new JobService();

View File

@@ -13,10 +13,12 @@ services:
volumes: volumes:
- ./backend:/app - ./backend:/app
- /app/node_modules - /app/node_modules
- ${DEPLOYMENTS_ROOT_HOST}:/workspace - ${PWD}:${PWD}
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
env_file: env_file:
- ./backend/.env - ./.env
environment:
DEPLOYMENTS_ROOT: ${PWD}/deployments
ports: ports:
- "4000:4000" - "4000:4000"
depends_on: depends_on:
@@ -29,7 +31,7 @@ services:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
env_file: env_file:
- ./frontend/.env - ./.env
ports: ports:
- "5173:5173" - "5173:5173"
depends_on: depends_on:

View File

@@ -3,10 +3,12 @@ services:
build: ./backend build: ./backend
command: npm run build && npm start command: npm run build && npm start
volumes: volumes:
- ${DEPLOYMENTS_ROOT_HOST}:/workspace - ${PWD}:${PWD}
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
env_file: env_file:
- ./backend/.env - ./.env
environment:
DEPLOYMENTS_ROOT: ${PWD}/deployments
ports: ports:
- "4000:4000" - "4000:4000"
@@ -17,7 +19,7 @@ services:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
env_file: env_file:
- ./frontend/.env - ./.env
environment: environment:
ALLOWED_HOSTS: ${ALLOWED_HOSTS} ALLOWED_HOSTS: ${ALLOWED_HOSTS}
ports: ports:

View File

@@ -1,3 +0,0 @@
VITE_API_URL=http://localhost:4000/api
# Prod için izin verilecek host(lar), virgülle ayırabilirsiniz. Örn:
# ALLOWED_HOSTS=wisecolt-ci-frontend-ft2pzo-1c0eb3-188-245-185-248.traefik.me

View File

@@ -16,6 +16,7 @@
"@fortawesome/react-fontawesome": "^3.1.0", "@fortawesome/react-fontawesome": "^3.1.0",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.1.3",
"axios": "^1.5.1", "axios": "^1.5.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",

View File

@@ -14,6 +14,8 @@ export interface DeploymentProject {
webhookToken: string; webhookToken: string;
env: DeploymentEnv; env: DeploymentEnv;
port?: number; port?: number;
envContent?: string;
envExampleName?: string;
lastDeployAt?: string; lastDeployAt?: string;
lastStatus: DeploymentStatus; lastStatus: DeploymentStatus;
lastMessage?: string; lastMessage?: string;
@@ -60,6 +62,8 @@ export interface DeploymentInput {
branch: string; branch: string;
composeFile: ComposeFile; composeFile: ComposeFile;
port?: number; port?: number;
envContent?: string;
envExampleName?: string;
} }
export async function fetchDeployments(): Promise<DeploymentProject[]> { export async function fetchDeployments(): Promise<DeploymentProject[]> {
@@ -88,8 +92,12 @@ export async function deleteDeployment(id: string): Promise<void> {
await apiClient.delete(`/deployments/${id}`); await apiClient.delete(`/deployments/${id}`);
} }
export async function runDeployment(id: string): Promise<void> { export async function runDeployment(id: string, message?: string): Promise<void> {
await apiClient.post(`/deployments/${id}/run`); await apiClient.post(`/deployments/${id}/run`, message ? { message } : {});
}
export async function restartDeployment(id: string, message?: string): Promise<void> {
await apiClient.post(`/deployments/${id}/restart`, message ? { message } : {});
} }
export async function fetchDeployment(id: string): Promise<DeploymentDetailResponse> { export async function fetchDeployment(id: string): Promise<DeploymentDetailResponse> {
@@ -111,3 +119,13 @@ export async function fetchDeploymentComposeFiles(
}); });
return (data as { files: ComposeFile[] }).files; return (data as { files: ComposeFile[] }).files;
} }
export async function fetchDeploymentEnvExamples(
repoUrl: string,
branch: string
): Promise<Array<{ name: string; content: string }>> {
const { data } = await apiClient.get("/deployments/env-examples", {
params: { repoUrl, branch }
});
return (data as { examples: Array<{ name: string; content: string }> }).examples;
}

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "../../lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn("mt-4 ring-offset-background focus-visible:outline-none", className)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,12 +1,48 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft, faCloudArrowUp, faCopy, faHistory } from "@fortawesome/free-solid-svg-icons"; import {
faArrowLeft,
faCloudArrowUp,
faCopy,
faEye,
faEyeSlash,
faHistory,
faRotate
} from "@fortawesome/free-solid-svg-icons";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { JobStatusBadge } from "../components/JobStatusBadge"; import { JobStatusBadge } from "../components/JobStatusBadge";
import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments"; import { Label } from "../components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import {
DeploymentInput,
DeploymentProject,
DeploymentRun,
fetchDeployment,
fetchDeploymentBranches,
fetchDeploymentComposeFiles,
fetchDeploymentEnvExamples,
restartDeployment,
runDeployment,
updateDeployment
} from "../api/deployments";
import { useDeploymentStream } from "../providers/live-provider";
import { useSocket } from "../providers/socket-provider";
type FormState = {
_id?: string;
name: string;
repoUrl: string;
branch: string;
composeFile: DeploymentInput["composeFile"];
port: string;
};
type EnvExample = { name: string; content: string };
export function DeploymentDetailPage() { export function DeploymentDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -15,6 +51,29 @@ export function DeploymentDetailPage() {
const [runs, setRuns] = useState<DeploymentRun[]>([]); const [runs, setRuns] = useState<DeploymentRun[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [triggering, setTriggering] = useState(false); const [triggering, setTriggering] = useState(false);
const [restarting, setRestarting] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>({
name: "",
repoUrl: "",
branch: "main",
composeFile: "docker-compose.yml",
port: ""
});
const [branchOptions, setBranchOptions] = useState<string[]>([]);
const [branchLoading, setBranchLoading] = useState(false);
const [composeOptions, setComposeOptions] = useState<DeploymentInput["composeFile"][]>([]);
const [composeLoading, setComposeLoading] = useState(false);
const [envExamples, setEnvExamples] = useState<EnvExample[]>([]);
const [envLoading, setEnvLoading] = useState(false);
const [envContent, setEnvContent] = useState("");
const [envExampleName, setEnvExampleName] = useState("");
const [showEnv, setShowEnv] = useState(false);
const [activeTab, setActiveTab] = useState("details");
const stream = useDeploymentStream(id || "");
const socket = useSocket();
const isEdit = useMemo(() => !!form._id, [form._id]);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -27,12 +86,36 @@ export function DeploymentDetailPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
useEffect(() => {
if (!socket || !id) return;
socket.emit("deployment:subscribe", { deploymentId: id });
const handleRunUpdate = ({ deploymentId, run }: { deploymentId: string; run: DeploymentRun }) => {
if (deploymentId !== id) return;
setRuns((prev) => {
const existingIndex = prev.findIndex((item) => item._id === run._id);
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...run };
return next;
}
return [run, ...prev];
});
};
socket.on("deployment:run", handleRunUpdate);
return () => {
socket.emit("deployment:unsubscribe", { deploymentId: id });
socket.off("deployment:run", handleRunUpdate);
};
}, [socket, id]);
const webhookUrl = useMemo(() => { const webhookUrl = useMemo(() => {
if (!project) return ""; if (!project) return "";
return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`; return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`;
}, [project]); }, [project]);
const latestRun = runs[0]; const latestRun = runs[0];
const effectiveStatus = stream.status || project?.lastStatus || latestRun?.status || "idle";
const currentLogs = stream.logs.length > 0 ? stream.logs : latestRun?.logs || [];
const decorateLogLine = (line: string) => { const decorateLogLine = (line: string) => {
const lower = line.toLowerCase(); const lower = line.toLowerCase();
@@ -56,7 +139,20 @@ export function DeploymentDetailPage() {
const handleCopy = async () => { const handleCopy = async () => {
try { try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(webhookUrl); await navigator.clipboard.writeText(webhookUrl);
} else {
const textarea = document.createElement("textarea");
textarea.value = webhookUrl;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
if (!ok) throw new Error("copy failed");
}
toast.success("Webhook URL kopyalandı"); toast.success("Webhook URL kopyalandı");
} catch { } catch {
toast.error("Webhook URL kopyalanamadı"); toast.error("Webhook URL kopyalanamadı");
@@ -76,6 +172,155 @@ export function DeploymentDetailPage() {
} }
}; };
const handleRestart = async () => {
if (!id) return;
setRestarting(true);
try {
await restartDeployment(id, "restart");
toast.success("Restart tetiklendi");
} catch {
toast.error("Restart tetiklenemedi");
} finally {
setRestarting(false);
}
};
useEffect(() => {
const repoUrl = form.repoUrl.trim();
if (!repoUrl) {
setBranchOptions([]);
setComposeOptions([]);
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 repoUrl = form.repoUrl.trim();
const branch = form.branch.trim();
if (!repoUrl || !branch) {
setEnvExamples([]);
setEnvExampleName("");
setComposeOptions([]);
return;
}
const timer = setTimeout(async () => {
setComposeLoading(true);
try {
const files = await fetchDeploymentComposeFiles(repoUrl, branch);
setComposeOptions(files);
if (files.length > 0 && !files.includes(form.composeFile)) {
setForm((prev) => ({ ...prev, composeFile: files[0] }));
}
} catch {
setComposeOptions([]);
} finally {
setComposeLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [form.repoUrl, form.branch, form.composeFile]);
useEffect(() => {
const repoUrl = form.repoUrl.trim();
const branch = form.branch.trim();
if (!repoUrl || !branch) {
return;
}
const timer = setTimeout(async () => {
setEnvLoading(true);
try {
const examples = await fetchDeploymentEnvExamples(repoUrl, branch);
setEnvExamples(examples);
if (examples.length === 0) {
return;
}
const selected = examples.find((example) => example.name === envExampleName) || examples[0];
if (!isEdit || !envContent) {
setEnvExampleName(selected.name);
setEnvContent(selected.content);
}
} catch {
setEnvExamples([]);
} finally {
setEnvLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [form.repoUrl, form.branch, envExampleName, isEdit, envContent]);
const handleEdit = () => {
if (!project) return;
const { _id, name, repoUrl, branch, composeFile, port } = project;
setForm({
_id,
name,
repoUrl,
branch,
composeFile,
port: port ? String(port) : ""
});
setEnvContent(project.envContent || "");
setEnvExampleName(project.envExampleName || "");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true);
};
const handleClose = () => {
setModalOpen(false);
};
const handleSave = async () => {
if (!form._id) return;
setSaving(true);
try {
const payload: DeploymentInput = {
name: form.name,
repoUrl: form.repoUrl,
branch: form.branch,
composeFile: form.composeFile,
port: form.port ? Number(form.port) : undefined,
envContent: envContent.trim() ? envContent : undefined,
envExampleName: envExampleName || undefined
};
if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) {
toast.error("Tüm alanları doldurun");
setSaving(false);
return;
}
const updated = await updateDeployment(form._id, payload);
setProject(updated);
try {
await runDeployment(updated._id, "update deploy");
} catch {
toast.error("Deploy tetiklenemedi");
}
toast.success("Deployment güncellendi");
setModalOpen(false);
} catch {
toast.error("İşlem sırasında hata oluştu");
} finally {
setSaving(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground"> <div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
@@ -93,6 +338,7 @@ export function DeploymentDetailPage() {
} }
return ( return (
<>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -107,10 +353,14 @@ export function DeploymentDetailPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => navigate("/deployments", { state: { editDeploymentId: project._id } })} onClick={handleEdit}
> >
Düzenle Düzenle
</Button> </Button>
<Button onClick={handleRestart} disabled={restarting} className="gap-2">
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
{restarting ? "Restarting..." : "Restart"}
</Button>
<Button onClick={handleRun} disabled={triggering} className="gap-2"> <Button onClick={handleRun} disabled={triggering} className="gap-2">
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" /> <FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
{triggering ? "Deploying..." : "Deploy"} {triggering ? "Deploying..." : "Deploy"}
@@ -121,7 +371,7 @@ export function DeploymentDetailPage() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Genel Bilgiler</CardTitle> <CardTitle>Genel Bilgiler</CardTitle>
<JobStatusBadge status={project.lastStatus} /> <JobStatusBadge status={effectiveStatus} />
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 text-sm text-muted-foreground"> <CardContent className="grid gap-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -204,8 +454,8 @@ export function DeploymentDetailPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="max-h-72 overflow-auto rounded-md border border-border bg-black px-3 py-2 font-mono text-xs text-green-100"> <div className="max-h-72 overflow-auto rounded-md border border-border bg-black px-3 py-2 font-mono text-xs text-green-100">
{latestRun?.logs?.length ? ( {currentLogs.length ? (
latestRun.logs.map((line, idx) => ( [...currentLogs].reverse().map((line, idx) => (
<div key={idx} className="whitespace-pre-wrap"> <div key={idx} className="whitespace-pre-wrap">
{decorateLogLine(line)} {decorateLogLine(line)}
</div> </div>
@@ -217,5 +467,230 @@ export function DeploymentDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
<div
className="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"
style={{ height: 620 }}
>
<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">Deployment Güncelle</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="flex-1 overflow-hidden px-5 py-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="details">Genel</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
</TabsList>
<TabsContent value="details" className="h-[420px] space-y-4">
{!isEdit && (
<div className="h-[1.25rem] text-xs text-muted-foreground">
Repo URL girildiğinde branch ve compose dosyaları listelenir.
</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="h-[1.25rem] 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>
{(composeOptions.length > 0
? composeOptions
: ["docker-compose.yml", "docker-compose.dev.yml"]
).map((file) => (
<SelectItem key={file} value={file}>
{file}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="h-[1.25rem] text-xs text-muted-foreground">
{composeLoading
? "Compose dosyaları alınıyor..."
: composeOptions.length > 0
? "Repo üzerindeki compose dosyaları listelendi."
: "Repo URL ve branch sonrası compose dosyaları listelenir."}
</div>
</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>
</TabsContent>
<TabsContent value="environment" className="h-[420px] space-y-4">
<div className="space-y-2">
<Label>.env.example</Label>
{envExamples.length > 0 ? (
<Select
value={envExampleName}
onValueChange={(value) => {
const example = envExamples.find((item) => item.name === value);
setEnvExampleName(value);
if (example) {
setEnvContent(example.content);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Env example seçin" />
</SelectTrigger>
<SelectContent>
{envExamples.map((example) => (
<SelectItem key={example.name} value={example.name}>
{example.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="h-[2.5rem] rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
{envLoading
? "Env example dosyaları alınıyor..."
: "Repo içinde .env.example bulunamadı."}
</div>
)}
<div className="h-[1.25rem] text-xs text-muted-foreground">
{envExamples.length > 0
? "Repo üzerindeki env example dosyaları listelendi."
: envLoading
? "Env example dosyaları alınıyor..."
: "Repo içinde .env.example bulunamadı."}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="env-content">Environment</Label>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowEnv((prev) => !prev)}
>
<FontAwesomeIcon icon={showEnv ? faEyeSlash : faEye} className="h-4 w-4" />
</Button>
</div>
<textarea
id="env-content"
value={envContent}
onChange={(e) => setEnvContent(e.target.value)}
className="h-[180px] w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm font-mono text-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={
showEnv ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties)
}
placeholder="ENV içerikleri burada listelenir."
/>
<div className="min-h-[1.25rem] text-xs text-muted-foreground">
Kaydedince içerik deployment kök dizinine{" "}
<span className="font-mono">.env</span> olarak yazılır.
</div>
</div>
</TabsContent>
</Tabs>
</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

@@ -1,9 +1,12 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
faCloudArrowUp, faCloudArrowUp,
faEye,
faEyeSlash,
faPenToSquare,
faPlus, faPlus,
faRotate, faRotate,
faRocket faRocket
@@ -13,6 +16,7 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label"; import { Label } from "../components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { import {
createDeployment, createDeployment,
deleteDeployment, deleteDeployment,
@@ -20,11 +24,14 @@ import {
DeploymentProject, DeploymentProject,
fetchDeploymentComposeFiles, fetchDeploymentComposeFiles,
fetchDeploymentBranches, fetchDeploymentBranches,
fetchDeploymentEnvExamples,
fetchDeployments, fetchDeployments,
restartDeployment,
runDeployment, runDeployment,
updateDeployment updateDeployment
} from "../api/deployments"; } from "../api/deployments";
import { JobStatusBadge } from "../components/JobStatusBadge"; import { JobStatusBadge } from "../components/JobStatusBadge";
import { useLiveData } from "../providers/live-provider";
type FormState = { type FormState = {
_id?: string; _id?: string;
@@ -35,6 +42,8 @@ type FormState = {
port: string; port: string;
}; };
type EnvExample = { name: string; content: string };
const defaultForm: FormState = { const defaultForm: FormState = {
name: "", name: "",
repoUrl: "", repoUrl: "",
@@ -46,6 +55,7 @@ const defaultForm: FormState = {
export function DeploymentsPage() { export function DeploymentsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { deploymentStreams } = useLiveData();
const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, ""); const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "");
const [deployments, setDeployments] = useState<DeploymentProject[]>([]); const [deployments, setDeployments] = useState<DeploymentProject[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -57,6 +67,12 @@ export function DeploymentsPage() {
const [branchLoading, setBranchLoading] = useState(false); const [branchLoading, setBranchLoading] = useState(false);
const [composeOptions, setComposeOptions] = useState<DeploymentInput["composeFile"][]>([]); const [composeOptions, setComposeOptions] = useState<DeploymentInput["composeFile"][]>([]);
const [composeLoading, setComposeLoading] = useState(false); const [composeLoading, setComposeLoading] = useState(false);
const [envExamples, setEnvExamples] = useState<EnvExample[]>([]);
const [envLoading, setEnvLoading] = useState(false);
const [envContent, setEnvContent] = useState("");
const [envExampleName, setEnvExampleName] = useState("");
const [showEnv, setShowEnv] = useState(false);
const [activeTab, setActiveTab] = useState("details");
const [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({}); const [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({});
const isEdit = useMemo(() => !!form._id, [form._id]); const isEdit = useMemo(() => !!form._id, [form._id]);
@@ -84,6 +100,14 @@ export function DeploymentsPage() {
setComposeOptions([]); setComposeOptions([]);
return; return;
} }
if (!form._id && !form.name) {
const normalized = repoUrl.replace(/\/+$/, "");
const lastPart = normalized.split("/").pop() || "";
const name = lastPart.replace(/\.git$/i, "");
if (name) {
setForm((prev) => ({ ...prev, name }));
}
}
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
setBranchLoading(true); setBranchLoading(true);
try { try {
@@ -105,6 +129,11 @@ export function DeploymentsPage() {
const repoUrl = form.repoUrl.trim(); const repoUrl = form.repoUrl.trim();
const branch = form.branch.trim(); const branch = form.branch.trim();
if (!repoUrl || !branch) { if (!repoUrl || !branch) {
setEnvExamples([]);
setEnvExampleName("");
if (!isEdit) {
setEnvContent("");
}
setComposeOptions([]); setComposeOptions([]);
return; return;
} }
@@ -125,6 +154,38 @@ export function DeploymentsPage() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [form.repoUrl, form.branch, form.composeFile]); }, [form.repoUrl, form.branch, form.composeFile]);
useEffect(() => {
const repoUrl = form.repoUrl.trim();
const branch = form.branch.trim();
if (!repoUrl || !branch) {
return;
}
const timer = setTimeout(async () => {
setEnvLoading(true);
try {
const examples = await fetchDeploymentEnvExamples(repoUrl, branch);
setEnvExamples(examples);
if (examples.length === 0) {
if (!isEdit) {
setEnvExampleName("");
setEnvContent("");
}
return;
}
const selected = examples.find((example) => example.name === envExampleName) || examples[0];
if (!isEdit || !envContent) {
setEnvExampleName(selected.name);
setEnvContent(selected.content);
}
} catch {
setEnvExamples([]);
} finally {
setEnvLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [form.repoUrl, form.branch, envExampleName, isEdit]);
useEffect(() => { useEffect(() => {
const state = location.state as { editDeploymentId?: string } | null; const state = location.state as { editDeploymentId?: string } | null;
if (state?.editDeploymentId) { if (state?.editDeploymentId) {
@@ -146,6 +207,11 @@ export function DeploymentsPage() {
setForm(defaultForm); setForm(defaultForm);
setBranchOptions([]); setBranchOptions([]);
setComposeOptions([]); setComposeOptions([]);
setEnvExamples([]);
setEnvContent("");
setEnvExampleName("");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true); setModalOpen(true);
}; };
@@ -159,6 +225,10 @@ export function DeploymentsPage() {
composeFile, composeFile,
port: port ? String(port) : "" port: port ? String(port) : ""
}); });
setEnvContent(deployment.envContent || "");
setEnvExampleName(deployment.envExampleName || "");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true); setModalOpen(true);
}; };
@@ -174,7 +244,9 @@ export function DeploymentsPage() {
repoUrl: form.repoUrl, repoUrl: form.repoUrl,
branch: form.branch, branch: form.branch,
composeFile: form.composeFile, composeFile: form.composeFile,
port: form.port ? Number(form.port) : undefined port: form.port ? Number(form.port) : undefined,
envContent: envContent.trim() ? envContent : undefined,
envExampleName: envExampleName || undefined
}; };
if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) { if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) {
@@ -189,9 +261,16 @@ export function DeploymentsPage() {
repoUrl: payload.repoUrl, repoUrl: payload.repoUrl,
branch: payload.branch, branch: payload.branch,
composeFile: payload.composeFile, composeFile: payload.composeFile,
port: payload.port port: payload.port,
envContent: payload.envContent,
envExampleName: payload.envExampleName
}); });
setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d))); setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d)));
try {
await runDeployment(updated._id, "update deploy");
} catch {
toast.error("Deploy tetiklenemedi");
}
toast.success("Deployment güncellendi"); toast.success("Deployment güncellendi");
} else { } else {
const created = await createDeployment(payload); const created = await createDeployment(payload);
@@ -216,6 +295,15 @@ export function DeploymentsPage() {
} }
}; };
const handleRestart = async (id: string) => {
try {
await restartDeployment(id, "restart");
toast.success("Restart tetiklendi");
} catch {
toast.error("Restart tetiklenemedi");
}
};
const handleDelete = async (deployment: DeploymentProject) => { const handleDelete = async (deployment: DeploymentProject) => {
const ok = window.confirm("Bu deployment'ı silmek istediğinize emin misiniz?"); const ok = window.confirm("Bu deployment'ı silmek istediğinize emin misiniz?");
if (!ok) return; if (!ok) return;
@@ -292,7 +380,9 @@ export function DeploymentsPage() {
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<JobStatusBadge status={deployment.lastStatus} /> <JobStatusBadge
status={deploymentStreams[deployment._id]?.status || deployment.lastStatus}
/>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80"> <span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
{deployment.env.toUpperCase()} {deployment.env.toUpperCase()}
</span> </span>
@@ -314,6 +404,18 @@ export function DeploymentsPage() {
> >
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" /> <FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
</Button> </Button>
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleRestart(deployment._id);
}}
title="Restart"
aria-label="Restart"
>
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
</Button>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
@@ -323,7 +425,7 @@ export function DeploymentsPage() {
}} }}
title="Düzenle" title="Düzenle"
> >
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" /> <FontAwesomeIcon icon={faPenToSquare} className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -361,7 +463,10 @@ export function DeploymentsPage() {
{modalOpen && ( {modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8"> <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 w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"
style={{ height: 626 }}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4"> <div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="space-y-1"> <div className="space-y-1">
<div className="text-lg font-semibold text-foreground"> <div className="text-lg font-semibold text-foreground">
@@ -376,9 +481,16 @@ export function DeploymentsPage() {
</Button> </Button>
</div> </div>
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4"> <div className="flex-1 overflow-hidden px-5 py-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="details">Genel</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
</TabsList>
<TabsContent value="details" className="h-[420px] space-y-4">
{!isEdit && ( {!isEdit && (
<div className="text-xs text-muted-foreground"> <div className="h-[1.25rem] text-xs text-muted-foreground">
Repo URL girildiğinde branch ve compose dosyaları listelenir. Repo URL girildiğinde branch ve compose dosyaları listelenir.
</div> </div>
)} )}
@@ -432,7 +544,7 @@ export function DeploymentsPage() {
required required
/> />
)} )}
<div className="text-xs text-muted-foreground"> <div className="h-[1.25rem] text-xs text-muted-foreground">
{branchLoading {branchLoading
? "Branch listesi alınıyor..." ? "Branch listesi alınıyor..."
: branchOptions.length > 0 : branchOptions.length > 0
@@ -448,7 +560,10 @@ export function DeploymentsPage() {
<Select <Select
value={form.composeFile} value={form.composeFile}
onValueChange={(value) => onValueChange={(value) =>
setForm((prev) => ({ ...prev, composeFile: value as DeploymentInput["composeFile"] })) setForm((prev) => ({
...prev,
composeFile: value as DeploymentInput["composeFile"]
}))
} }
> >
<SelectTrigger> <SelectTrigger>
@@ -465,7 +580,7 @@ export function DeploymentsPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<div className="text-xs text-muted-foreground"> <div className="h-[1.25rem] text-xs text-muted-foreground">
{composeLoading {composeLoading
? "Compose dosyaları alınıyor..." ? "Compose dosyaları alınıyor..."
: composeOptions.length > 0 : composeOptions.length > 0
@@ -486,6 +601,77 @@ export function DeploymentsPage() {
/> />
</div> </div>
</div> </div>
</TabsContent>
<TabsContent value="environment" className="h-[420px] space-y-4">
<div className="space-y-2">
<Label>.env.example</Label>
{envExamples.length > 0 ? (
<Select
value={envExampleName}
onValueChange={(value) => {
const example = envExamples.find((item) => item.name === value);
setEnvExampleName(value);
if (example) {
setEnvContent(example.content);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Env example seçin" />
</SelectTrigger>
<SelectContent>
{envExamples.map((example) => (
<SelectItem key={example.name} value={example.name}>
{example.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="h-[2.5rem] rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
{envLoading
? "Env example dosyaları alınıyor..."
: "Repo içinde .env.example bulunamadı."}
</div>
)}
<div className="h-[1.25rem] text-xs text-muted-foreground">
{envExamples.length > 0
? "Repo üzerindeki env example dosyaları listelendi."
: envLoading
? "Env example dosyaları alınıyor..."
: "Repo içinde .env.example bulunamadı."}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="env-content">Environment</Label>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowEnv((prev) => !prev)}
>
<FontAwesomeIcon icon={showEnv ? faEyeSlash : faEye} className="h-4 w-4" />
</Button>
</div>
<textarea
id="env-content"
value={envContent}
onChange={(e) => setEnvContent(e.target.value)}
className="h-[180px] w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm font-mono text-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={
showEnv ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties)
}
placeholder="ENV içerikleri burada listelenir."
/>
<div className="min-h-[1.25rem] text-xs text-muted-foreground">
Kaydedince içerik deployment kök dizinine <span className="font-mono">.env</span> olarak yazılır.
</div>
</div>
</TabsContent>
</Tabs>
</div> </div>
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4"> <div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { import {
Line, Line,
LineChart, LineChart,
@@ -18,6 +18,7 @@ import { JobStatusBadge } from "../components/JobStatusBadge";
import { RepoIcon } from "../components/RepoIcon"; import { RepoIcon } from "../components/RepoIcon";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClockRotateLeft, faListCheck, faFlaskVial, faRocket } from "@fortawesome/free-solid-svg-icons"; import { faClockRotateLeft, faListCheck, faFlaskVial, faRocket } from "@fortawesome/free-solid-svg-icons";
import { useAuth } from "../providers/auth-provider";
function formatDuration(ms?: number) { function formatDuration(ms?: number) {
if (!ms || Number.isNaN(ms)) return "-"; if (!ms || Number.isNaN(ms)) return "-";
@@ -41,9 +42,14 @@ export function HomePage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { jobStreams } = useLiveData(); const { jobStreams } = useLiveData();
const { token } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
useEffect(() => { const loadMetrics = useCallback(() => {
if (!token) return;
setLoading(true);
setError(null);
Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()]) Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()])
.then(([jobResult, deployResult]) => { .then(([jobResult, deployResult]) => {
if (jobResult.status === "fulfilled") { if (jobResult.status === "fulfilled") {
@@ -65,7 +71,25 @@ export function HomePage() {
} }
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, [token]);
useEffect(() => {
loadMetrics();
}, [loadMetrics, location.key]);
useEffect(() => {
const handleFocus = () => {
if (document.visibilityState === "visible") {
loadMetrics();
}
};
window.addEventListener("focus", handleFocus);
document.addEventListener("visibilitychange", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleFocus);
};
}, [loadMetrics]);
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (!metrics) { if (!metrics) {
@@ -273,6 +297,7 @@ export function HomePage() {
<RepoIcon repoUrl={run.repoUrl} /> <RepoIcon repoUrl={run.repoUrl} />
<div> <div>
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground"> <div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
<span>{run.title}</span>
<span <span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${ className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${
run.type === "test" run.type === "test"
@@ -286,7 +311,6 @@ export function HomePage() {
/> />
{run.type === "test" ? "Test" : "Deploy"} {run.type === "test" ? "Test" : "Deploy"}
</span> </span>
<span>{run.title}</span>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{new Date(run.startedAt).toLocaleString()} · Süre:{" "} {new Date(run.startedAt).toLocaleString()} · Süre:{" "}

View File

@@ -23,7 +23,20 @@ export function SettingsPage() {
const handleCopy = async (value: string, label: string) => { const handleCopy = async (value: string, label: string) => {
try { try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
} else {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
if (!ok) throw new Error("copy failed");
}
toast.success(`${label} kopyalandı`); toast.success(`${label} kopyalandı`);
} catch { } catch {
toast.error(`${label} kopyalanamadı`); toast.error(`${label} kopyalanamadı`);

View File

@@ -23,7 +23,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setToken(stored); setToken(stored);
fetchMe() fetchMe()
.then((data) => setUser({ username: data.username })) .then((data) => setUser({ username: data.username }))
.catch(() => setAuthToken(undefined)) .catch(() => {
setAuthToken(undefined);
setToken(null);
setUser(null);
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
} else { } else {
setLoading(false); setLoading(false);

View File

@@ -12,6 +12,7 @@ type JobStream = {
type LiveContextValue = { type LiveContextValue = {
jobStreams: Record<string, JobStream>; jobStreams: Record<string, JobStream>;
deploymentStreams: Record<string, JobStream>;
}; };
const LiveContext = createContext<LiveContextValue | undefined>(undefined); const LiveContext = createContext<LiveContextValue | undefined>(undefined);
@@ -19,6 +20,7 @@ const LiveContext = createContext<LiveContextValue | undefined>(undefined);
export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const socket = useSocket(); const socket = useSocket();
const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({}); const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({});
const [deploymentStreams, setDeploymentStreams] = useState<Record<string, JobStream>>({});
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
@@ -54,20 +56,59 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children
}); });
}; };
const handleDeploymentLog = ({ deploymentId, line }: { deploymentId: string; line: string }) => {
if (!deploymentId) return;
setDeploymentStreams((prev) => {
const current = prev[deploymentId] || { logs: [] };
const nextLogs = [...current.logs, line].slice(-200);
return { ...prev, [deploymentId]: { ...current, logs: nextLogs } };
});
};
const handleDeploymentStatus = ({
deploymentId,
status,
lastRunAt,
lastMessage,
runCount,
lastDurationMs
}: {
deploymentId: string;
status?: string;
lastRunAt?: string;
lastMessage?: string;
runCount?: number;
lastDurationMs?: number;
}) => {
if (!deploymentId) return;
setDeploymentStreams((prev) => {
const current = prev[deploymentId] || { logs: [] };
return {
...prev,
[deploymentId]: { ...current, status, lastRunAt, lastMessage, runCount, lastDurationMs }
};
});
};
socket.on("job:log", handleJobLog); socket.on("job:log", handleJobLog);
socket.on("job:status", handleJobStatus); socket.on("job:status", handleJobStatus);
socket.on("deployment:log", handleDeploymentLog);
socket.on("deployment:status", handleDeploymentStatus);
return () => { return () => {
socket.off("job:log", handleJobLog); socket.off("job:log", handleJobLog);
socket.off("job:status", handleJobStatus); socket.off("job:status", handleJobStatus);
socket.off("deployment:log", handleDeploymentLog);
socket.off("deployment:status", handleDeploymentStatus);
}; };
}, [socket]); }, [socket]);
const value = useMemo( const value = useMemo(
() => ({ () => ({
jobStreams jobStreams,
deploymentStreams
}), }),
[jobStreams] [jobStreams, deploymentStreams]
); );
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>; return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
@@ -87,3 +128,12 @@ export function useJobStream(jobId: string) {
[ctx.jobStreams, jobId] [ctx.jobStreams, jobId]
); );
} }
export function useDeploymentStream(deploymentId: string) {
const ctx = useContext(LiveContext);
if (!ctx) throw new Error("useDeploymentStream LiveProvider içinde kullanılmalı");
return useMemo(
() => ctx.deploymentStreams[deploymentId] || { logs: [], status: "idle", runCount: 0 },
[ctx.deploymentStreams, deploymentId]
);
}

View File

@@ -10,7 +10,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const socketRef = useRef<Socket | null>(null); const socketRef = useRef<Socket | null>(null);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const baseUrl = useMemo(() => apiClient.defaults.baseURL || window.location.origin, []); const baseUrl = useMemo(() => window.location.origin, []);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -22,6 +22,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const socket = io(baseUrl, { const socket = io(baseUrl, {
auth: { token }, auth: { token },
path: "/api/socket.io",
transports: ["websocket", "polling"] transports: ["websocket", "polling"]
}); });