Compare commits

..

14 Commits

Author SHA1 Message Date
719ae4044e refactor(ui): deploy geçmişi için native scroll kullan 2026-02-03 10:08:45 +00:00
e2b9f19800 feat(ui): deploy geçmişi için scroll-area ekle 2026-02-03 10:08:18 +00:00
064a04d898 refactor(settings): ayarlar sayfası düzenini güncelle 2026-02-03 09:37:09 +00:00
1f90ce54d4 feat(settings): otomatik docker image temizliği ekle
Docker image temizliği için yapılandırılabilir zamanlayıcı ve manuel
tetikleme özelliği eklenmiştir. Kullanıcılar saat, gün veya hafta bazlı
periyotlar belirleyebilir ve anlık temizlik yapabilir.
2026-02-03 09:34:37 +00:00
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
aa12881c4b fix(deployments): deployment kök yolunu sabitle 2026-01-19 13:31:29 +03:00
28 changed files with 2123 additions and 203 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 Config ===
API_KEY_LITE="your-lite-key"
@@ -7,6 +18,3 @@ ACTIVE_KEY=lite
# === Anthropic API Settings ===
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
ANTHROPIC_MODEL="glm-4.7"
# Host üzerinde projelerin bulunduğu dizin (compose volume için, zorunludur)
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ import mongoose, { Schema, Document } from "mongoose";
export interface SettingsDocument extends Document {
webhookToken: string;
webhookSecret: string;
cleanupIntervalValue?: number;
cleanupIntervalUnit?: "saat" | "gün" | "hafta";
createdAt: Date;
updatedAt: Date;
}
@@ -10,7 +12,9 @@ export interface SettingsDocument extends Document {
const SettingsSchema = new Schema<SettingsDocument>(
{
webhookToken: { type: String, required: true },
webhookSecret: { type: String, required: true }
webhookSecret: { type: String, required: true },
cleanupIntervalValue: { type: Number, min: 1 },
cleanupIntervalUnit: { type: String, enum: ["saat", "gün", "hafta"] }
},
{ timestamps: true }
);

View File

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

View File

@@ -9,7 +9,11 @@ const router = Router();
router.use(authMiddleware);
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([
{ $group: { _id: "$job", runCount: { $sum: 1 } } }
]);
@@ -26,6 +30,10 @@ router.get("/", 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();
since.setDate(since.getDate() - 7);
@@ -87,6 +95,7 @@ router.post("/", async (req, res) => {
}
try {
const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit });
await jobService.persistMetadata(job);
jobService.scheduleJob(job);
// Yeni job oluşturulduğunda ilk test otomatik tetiklensin
jobService.runJob(job._id.toString()).catch(() => undefined);
@@ -106,6 +115,7 @@ router.put("/:id", async (req, res) => {
{ new: true, runValidators: true }
);
if (!job) return res.status(404).json({ message: "Job bulunamadı" });
await jobService.persistMetadata(job);
jobService.scheduleJob(job);
return res.json(job);
} catch (err) {

View File

@@ -11,6 +11,8 @@ router.get("/", async (_req, res) => {
return res.json({
webhookToken: settings.webhookToken,
webhookSecret: settings.webhookSecret,
cleanupIntervalValue: settings.cleanupIntervalValue,
cleanupIntervalUnit: settings.cleanupIntervalUnit,
updatedAt: settings.updatedAt
});
});
@@ -31,4 +33,29 @@ router.post("/secret/rotate", async (_req, res) => {
});
});
router.post("/cleanup-interval", async (req, res) => {
const settings = await deploymentService.ensureSettings();
const { value, unit } = req.body as {
value?: number;
unit?: "saat" | "gün" | "hafta";
};
if (!value || value < 1 || !unit) {
return res.status(400).json({ message: "Geçerli periyot gerekli" });
}
settings.cleanupIntervalValue = value;
settings.cleanupIntervalUnit = unit;
await settings.save();
await deploymentService.updateCleanupSchedule(value, unit);
return res.json({
cleanupIntervalValue: settings.cleanupIntervalValue,
cleanupIntervalUnit: settings.cleanupIntervalUnit,
updatedAt: settings.updatedAt
});
});
router.post("/cleanup-images", async (_req, res) => {
await deploymentService.cleanupUnusedImages();
return res.json({ success: true });
});
export default router;

View File

@@ -1,6 +1,6 @@
import { Router, Request } from "express";
import crypto from "crypto";
import { deploymentService } from "../services/deploymentService.js";
import { deploymentService, normalizeCommitMessage } from "../services/deploymentService.js";
const router = Router();
@@ -18,6 +18,12 @@ function verifySignature(rawBody: Buffer, secret: string, signature: string) {
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) => {
const { token } = req.params;
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 ref = payload?.ref || "";
const branch = ref.startsWith("refs/heads/") ? ref.replace("refs/heads/", "") : ref;
const commitMessage =
const branch = normalizeBranch(ref);
const commitMessageRaw =
payload?.head_commit?.message || payload?.commits?.[payload.commits.length - 1]?.message;
const commitMessage = normalizeCommitMessage(commitMessageRaw);
const project = await deploymentService.findByWebhookToken(token);
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 });
}

View File

@@ -2,18 +2,201 @@ import fs from "fs";
import path from "path";
import crypto from "crypto";
import { spawn } from "child_process";
import { Server } from "socket.io";
import { config } from "../config/env.js";
import {
DeploymentProject,
DeploymentProjectDocument,
ComposeFile,
DeploymentEnv
} from "../models/deploymentProject.js";
import { DeploymentRun } from "../models/deploymentRun.js";
import { DeploymentRun, DeploymentRunDocument } from "../models/deploymentRun.js";
import { Settings } from "../models/settings.js";
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
const deploymentsRoot = path.join(process.cwd(), "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;
cleanupIntervalValue?: number;
cleanupIntervalUnit?: "saat" | "gün" | "hafta";
};
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) {
return value
@@ -118,11 +301,37 @@ async function ensureRepo(project: DeploymentProjectDocument, onData: (line: str
if (!exists) {
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");
}
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}`);
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 {
onData("Repo güncelleniyor (git fetch/pull)...");
await runCommand(`git fetch origin ${project.branch}`, repoDir, onData);
@@ -152,6 +361,40 @@ async function runCompose(project: DeploymentProjectDocument, onData: (line: str
class DeploymentService {
private running: Map<string, boolean> = new Map();
private io: Server | null = null;
private cleanupTimer: NodeJS.Timeout | 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) {
const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd());
@@ -183,13 +426,65 @@ 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() {
const existing = await Settings.findOne();
if (existing) return existing;
if (existing) {
await this.updateCleanupSchedule(existing.cleanupIntervalValue, existing.cleanupIntervalUnit);
return existing;
}
const fileSettings = await readSettingsFile();
if (fileSettings) {
const createdFromFile = await Settings.create({
webhookToken: fileSettings.webhookToken,
webhookSecret: fileSettings.webhookSecret,
cleanupIntervalValue: fileSettings.cleanupIntervalValue,
cleanupIntervalUnit: fileSettings.cleanupIntervalUnit
});
await this.updateCleanupSchedule(
createdFromFile.cleanupIntervalValue,
createdFromFile.cleanupIntervalUnit
);
return createdFromFile;
}
const created = await Settings.create({
webhookToken: generateApiToken(),
webhookSecret: generateSecret()
});
await writeSettingsFile({
webhookToken: created.webhookToken,
webhookSecret: created.webhookSecret,
cleanupIntervalValue: created.cleanupIntervalValue,
cleanupIntervalUnit: created.cleanupIntervalUnit
});
await this.updateCleanupSchedule(created.cleanupIntervalValue, created.cleanupIntervalUnit);
return created;
}
@@ -197,6 +492,12 @@ class DeploymentService {
const settings = await this.ensureSettings();
settings.webhookToken = generateApiToken();
await settings.save();
await writeSettingsFile({
webhookToken: settings.webhookToken,
webhookSecret: settings.webhookSecret,
cleanupIntervalValue: settings.cleanupIntervalValue,
cleanupIntervalUnit: settings.cleanupIntervalUnit
});
return settings;
}
@@ -204,15 +505,45 @@ class DeploymentService {
const settings = await this.ensureSettings();
settings.webhookSecret = generateSecret();
await settings.save();
await writeSettingsFile({
webhookToken: settings.webhookToken,
webhookSecret: settings.webhookSecret,
cleanupIntervalValue: settings.cleanupIntervalValue,
cleanupIntervalUnit: settings.cleanupIntervalUnit
});
return settings;
}
async updateCleanupSchedule(value?: number, unit?: "saat" | "gün" | "hafta") {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
if (!value || !unit) return;
const intervalMs =
unit === "saat"
? value * 60 * 60 * 1000
: unit === "gün"
? value * 24 * 60 * 60 * 1000
: value * 7 * 24 * 60 * 60 * 1000;
if (!intervalMs || Number.isNaN(intervalMs)) return;
this.cleanupTimer = setInterval(() => {
this.cleanupUnusedImages().catch(() => undefined);
}, intervalMs);
}
async cleanupUnusedImages() {
await runCommand("docker image prune -a -f", process.cwd(), () => undefined);
}
async createProject(input: {
name: string;
repoUrl: string;
branch: string;
composeFile: ComposeFile;
port?: number;
envContent?: string;
envExampleName?: string;
}) {
const repoUrl = normalizeRepoUrl(input.repoUrl);
const existingRepo = await DeploymentProject.findOne({ repoUrl });
@@ -238,7 +569,7 @@ class DeploymentService {
}
const env = deriveEnv(input.composeFile);
return DeploymentProject.create({
const created = await DeploymentProject.create({
name: input.name,
rootPath,
repoUrl,
@@ -246,8 +577,22 @@ class DeploymentService {
composeFile: input.composeFile,
webhookToken,
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(
@@ -258,6 +603,8 @@ class DeploymentService {
branch: string;
composeFile: ComposeFile;
port?: number;
envContent?: string;
envExampleName?: string;
}
) {
const project = await DeploymentProject.findById(id);
@@ -282,10 +629,25 @@ class DeploymentService {
branch: input.branch,
composeFile: input.composeFile,
env,
port: input.port
port: input.port,
envContent: input.envContent,
envExampleName: input.envExampleName
},
{ 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;
}
@@ -301,41 +663,61 @@ class DeploymentService {
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: options?.message
message: normalizedMessage ?? options?.message
});
this.emitRun(projectId, runDoc);
await writeRunFile(project.rootPath, runDoc);
await DeploymentProject.findByIdAndUpdate(projectId, {
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 {
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...");
await runCompose(project, (line) => pushLog(line));
const duration = Date.now() - startedAt;
await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "success",
lastDeployAt: new Date(),
lastMessage: options?.message || "Başarılı"
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, {
status: "success",
finishedAt: new Date(),
durationMs: duration,
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ı");
} catch (err) {
const duration = Date.now() - startedAt;
@@ -344,22 +726,258 @@ class DeploymentService {
lastDeployAt: new Date(),
lastMessage: (err as Error).message
});
await this.emitStatus(projectId, {
lastStatus: "failed",
lastDeployAt: new Date(),
lastMessage: (err as Error).message
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "failed",
finishedAt: new Date(),
durationMs: duration,
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}`);
} finally {
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) {
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();

View File

@@ -3,9 +3,11 @@ import path from "path";
import { spawn } from "child_process";
import { Server } from "socket.io";
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 jobMetadataFileName = ".wisecolt-ci-job.json";
const jobRunsDirName = ".wisecolt-ci-job-runs";
function unitToMs(unit: TimeUnit) {
if (unit === "dakika") return 60_000;
@@ -17,6 +19,91 @@ function ensureDir(dir: string) {
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) {
// ANSI escape sequences temizleme
return input.replace(
@@ -85,8 +172,42 @@ async function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) {
const exists = fs.existsSync(gitDir);
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}`);
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 {
onData("Repo güncelleniyor (git pull)...");
await runCommand("git pull", repoDir, onData);
@@ -156,6 +277,7 @@ class JobService {
status: "running",
startedAt: new Date()
});
await writeJobRunFile(getJobDir(jobId), runDoc);
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);
@@ -179,6 +301,8 @@ class JobService {
durationMs: duration,
logs: runLogs
});
const updatedRun = await JobRun.findById(runDoc._id);
if (updatedRun) await writeJobRunFile(getJobDir(jobId), updatedRun);
await this.emitStatus(jobId, {
status: "success",
lastRunAt: new Date(),
@@ -199,6 +323,8 @@ class JobService {
durationMs: duration,
logs: runLogs
});
const updatedRun = await JobRun.findById(runDoc._id);
if (updatedRun) await writeJobRunFile(getJobDir(jobId), updatedRun);
pushLog(`Hata: ${(err as Error).message}`);
await this.emitStatus(jobId, {
status: "failed",
@@ -231,6 +357,78 @@ class JobService {
const jobs = await Job.find();
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();

View File

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

View File

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

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,8 @@
"@fortawesome/react-fontawesome": "^3.1.0",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-scroll-area": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.3",
"axios": "^1.5.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",

View File

@@ -14,6 +14,8 @@ export interface DeploymentProject {
webhookToken: string;
env: DeploymentEnv;
port?: number;
envContent?: string;
envExampleName?: string;
lastDeployAt?: string;
lastStatus: DeploymentStatus;
lastMessage?: string;
@@ -60,6 +62,8 @@ export interface DeploymentInput {
branch: string;
composeFile: ComposeFile;
port?: number;
envContent?: string;
envExampleName?: string;
}
export async function fetchDeployments(): Promise<DeploymentProject[]> {
@@ -88,8 +92,12 @@ export async function deleteDeployment(id: string): Promise<void> {
await apiClient.delete(`/deployments/${id}`);
}
export async function runDeployment(id: string): Promise<void> {
await apiClient.post(`/deployments/${id}/run`);
export async function runDeployment(id: string, message?: string): Promise<void> {
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> {
@@ -111,3 +119,13 @@ export async function fetchDeploymentComposeFiles(
});
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

@@ -3,6 +3,8 @@ import { apiClient } from "./client";
export interface SettingsResponse {
webhookToken: string;
webhookSecret: string;
cleanupIntervalValue?: number;
cleanupIntervalUnit?: "saat" | "gün" | "hafta";
updatedAt: string;
}
@@ -20,3 +22,13 @@ export async function rotateWebhookSecret(): Promise<SettingsResponse> {
const { data } = await apiClient.post("/settings/secret/rotate");
return data as SettingsResponse;
}
export async function saveCleanupInterval(value: number, unit: "saat" | "gün" | "hafta") {
const { data } = await apiClient.post("/settings/cleanup-interval", { value, unit });
return data as SettingsResponse;
}
export async function cleanupImages(): Promise<{ success: boolean }> {
const { data } = await apiClient.post("/settings/cleanup-images");
return data as { success: boolean };
}

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "../../lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.Scrollbar
orientation="vertical"
className="flex touch-none select-none p-0.5 transition-colors"
>
<ScrollAreaPrimitive.Thumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.Scrollbar>
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
export { ScrollArea };

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 { 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 { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
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() {
const { id } = useParams<{ id: string }>();
@@ -15,6 +51,29 @@ export function DeploymentDetailPage() {
const [runs, setRuns] = useState<DeploymentRun[]>([]);
const [loading, setLoading] = useState(true);
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(() => {
if (!id) return;
@@ -27,12 +86,36 @@ export function DeploymentDetailPage() {
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
if (!socket || !id) return;
socket.emit("deployment:subscribe", { deploymentId: id });
const handleRunUpdate = ({ deploymentId, run }: { deploymentId: string; run: DeploymentRun }) => {
if (deploymentId !== id) return;
setRuns((prev) => {
const existingIndex = prev.findIndex((item) => item._id === run._id);
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...run };
return next;
}
return [run, ...prev];
});
};
socket.on("deployment:run", handleRunUpdate);
return () => {
socket.emit("deployment:unsubscribe", { deploymentId: id });
socket.off("deployment:run", handleRunUpdate);
};
}, [socket, id]);
const webhookUrl = useMemo(() => {
if (!project) return "";
return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`;
}, [project]);
const latestRun = runs[0];
const effectiveStatus = stream.status || project?.lastStatus || latestRun?.status || "idle";
const currentLogs = stream.logs.length > 0 ? stream.logs : latestRun?.logs || [];
const decorateLogLine = (line: string) => {
const lower = line.toLowerCase();
@@ -56,7 +139,20 @@ export function DeploymentDetailPage() {
const handleCopy = async () => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(webhookUrl);
} else {
const textarea = document.createElement("textarea");
textarea.value = webhookUrl;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
if (!ok) throw new Error("copy failed");
}
toast.success("Webhook URL kopyalandı");
} catch {
toast.error("Webhook URL kopyalanamadı");
@@ -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) {
return (
<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 (
<>
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
@@ -107,10 +353,14 @@ export function DeploymentDetailPage() {
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => navigate("/deployments", { state: { editDeploymentId: project._id } })}
onClick={handleEdit}
>
Düzenle
</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">
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
{triggering ? "Deploying..." : "Deploy"}
@@ -121,7 +371,7 @@ export function DeploymentDetailPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Genel Bilgiler</CardTitle>
<JobStatusBadge status={project.lastStatus} />
<JobStatusBadge status={effectiveStatus} />
</CardHeader>
<CardContent className="grid gap-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center gap-3">
@@ -172,10 +422,11 @@ export function DeploymentDetailPage() {
Deploy Geçmişi
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{runs.length === 0 && (
<CardContent className="max-h-[520px] overflow-y-auto pr-2">
{runs.length === 0 ? (
<div className="text-sm text-muted-foreground">Henüz deploy çalıştırılmadı.</div>
)}
) : (
<div className="space-y-3">
{runs.map((run) => (
<div
key={run._id}
@@ -195,6 +446,8 @@ export function DeploymentDetailPage() {
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
@@ -204,8 +457,8 @@ export function DeploymentDetailPage() {
</CardHeader>
<CardContent>
<div className="max-h-72 overflow-auto rounded-md border border-border bg-black px-3 py-2 font-mono text-xs text-green-100">
{latestRun?.logs?.length ? (
latestRun.logs.map((line, idx) => (
{currentLogs.length ? (
[...currentLogs].reverse().map((line, idx) => (
<div key={idx} className="whitespace-pre-wrap">
{decorateLogLine(line)}
</div>
@@ -217,5 +470,230 @@ export function DeploymentDetailPage() {
</CardContent>
</Card>
</div>
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
<div
className="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 { useLocation, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCloudArrowUp,
faEye,
faEyeSlash,
faPenToSquare,
faPlus,
faRotate,
faRocket
@@ -13,6 +16,7 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import {
createDeployment,
deleteDeployment,
@@ -20,11 +24,14 @@ import {
DeploymentProject,
fetchDeploymentComposeFiles,
fetchDeploymentBranches,
fetchDeploymentEnvExamples,
fetchDeployments,
restartDeployment,
runDeployment,
updateDeployment
} from "../api/deployments";
import { JobStatusBadge } from "../components/JobStatusBadge";
import { useLiveData } from "../providers/live-provider";
type FormState = {
_id?: string;
@@ -35,6 +42,8 @@ type FormState = {
port: string;
};
type EnvExample = { name: string; content: string };
const defaultForm: FormState = {
name: "",
repoUrl: "",
@@ -46,6 +55,7 @@ const defaultForm: FormState = {
export function DeploymentsPage() {
const navigate = useNavigate();
const location = useLocation();
const { deploymentStreams } = useLiveData();
const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "");
const [deployments, setDeployments] = useState<DeploymentProject[]>([]);
const [loading, setLoading] = useState(false);
@@ -57,6 +67,12 @@ export function DeploymentsPage() {
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 [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({});
const isEdit = useMemo(() => !!form._id, [form._id]);
@@ -84,6 +100,14 @@ export function DeploymentsPage() {
setComposeOptions([]);
return;
}
if (!form._id && !form.name) {
const normalized = repoUrl.replace(/\/+$/, "");
const lastPart = normalized.split("/").pop() || "";
const name = lastPart.replace(/\.git$/i, "");
if (name) {
setForm((prev) => ({ ...prev, name }));
}
}
const timer = setTimeout(async () => {
setBranchLoading(true);
try {
@@ -105,6 +129,11 @@ export function DeploymentsPage() {
const repoUrl = form.repoUrl.trim();
const branch = form.branch.trim();
if (!repoUrl || !branch) {
setEnvExamples([]);
setEnvExampleName("");
if (!isEdit) {
setEnvContent("");
}
setComposeOptions([]);
return;
}
@@ -125,6 +154,38 @@ export function DeploymentsPage() {
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) {
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(() => {
const state = location.state as { editDeploymentId?: string } | null;
if (state?.editDeploymentId) {
@@ -146,6 +207,11 @@ export function DeploymentsPage() {
setForm(defaultForm);
setBranchOptions([]);
setComposeOptions([]);
setEnvExamples([]);
setEnvContent("");
setEnvExampleName("");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true);
};
@@ -159,6 +225,10 @@ export function DeploymentsPage() {
composeFile,
port: port ? String(port) : ""
});
setEnvContent(deployment.envContent || "");
setEnvExampleName(deployment.envExampleName || "");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true);
};
@@ -174,7 +244,9 @@ export function DeploymentsPage() {
repoUrl: form.repoUrl,
branch: form.branch,
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) {
@@ -189,9 +261,16 @@ export function DeploymentsPage() {
repoUrl: payload.repoUrl,
branch: payload.branch,
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)));
try {
await runDeployment(updated._id, "update deploy");
} catch {
toast.error("Deploy tetiklenemedi");
}
toast.success("Deployment güncellendi");
} else {
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 ok = window.confirm("Bu deployment'ı silmek istediğinize emin misiniz?");
if (!ok) return;
@@ -292,7 +380,9 @@ export function DeploymentsPage() {
</div>
</div>
<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">
{deployment.env.toUpperCase()}
</span>
@@ -314,6 +404,18 @@ export function DeploymentsPage() {
>
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
</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
variant="outline"
size="icon"
@@ -323,7 +425,7 @@ export function DeploymentsPage() {
}}
title="Düzenle"
>
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
<FontAwesomeIcon icon={faPenToSquare} className="h-4 w-4" />
</Button>
<Button
variant="outline"
@@ -361,7 +463,10 @@ export function DeploymentsPage() {
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-border bg-card card-shadow">
<div
className="flex 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="space-y-1">
<div className="text-lg font-semibold text-foreground">
@@ -376,9 +481,16 @@ export function DeploymentsPage() {
</Button>
</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 && (
<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.
</div>
)}
@@ -432,7 +544,7 @@ export function DeploymentsPage() {
required
/>
)}
<div className="text-xs text-muted-foreground">
<div className="h-[1.25rem] text-xs text-muted-foreground">
{branchLoading
? "Branch listesi alınıyor..."
: branchOptions.length > 0
@@ -448,7 +560,10 @@ export function DeploymentsPage() {
<Select
value={form.composeFile}
onValueChange={(value) =>
setForm((prev) => ({ ...prev, composeFile: value as DeploymentInput["composeFile"] }))
setForm((prev) => ({
...prev,
composeFile: value as DeploymentInput["composeFile"]
}))
}
>
<SelectTrigger>
@@ -465,7 +580,7 @@ export function DeploymentsPage() {
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
<div className="h-[1.25rem] text-xs text-muted-foreground">
{composeLoading
? "Compose dosyaları alınıyor..."
: composeOptions.length > 0
@@ -486,6 +601,77 @@ export function DeploymentsPage() {
/>
</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">

View File

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

View File

@@ -1,10 +1,20 @@
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy, faEye, faEyeSlash, faRotate } from "@fortawesome/free-solid-svg-icons";
import { faBroom, faCopy, faEye, faEyeSlash, faFloppyDisk, faRotate } from "@fortawesome/free-solid-svg-icons";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { fetchSettings, rotateWebhookSecret, rotateWebhookToken, SettingsResponse } from "../api/settings";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
import {
cleanupImages,
fetchSettings,
rotateWebhookSecret,
rotateWebhookToken,
saveCleanupInterval,
SettingsResponse
} from "../api/settings";
export function SettingsPage() {
const [settings, setSettings] = useState<SettingsResponse | null>(null);
@@ -13,17 +23,42 @@ export function SettingsPage() {
const [rotatingSecret, setRotatingSecret] = useState(false);
const [showToken, setShowToken] = useState(false);
const [showSecret, setShowSecret] = useState(false);
const [cleanupValue, setCleanupValue] = useState<string>("1");
const [cleanupUnit, setCleanupUnit] = useState<"saat" | "gün" | "hafta">("hafta");
const [savingCleanup, setSavingCleanup] = useState(false);
const [cleaning, setCleaning] = useState(false);
useEffect(() => {
fetchSettings()
.then((data) => setSettings(data))
.then((data) => {
setSettings(data);
if (data.cleanupIntervalValue) {
setCleanupValue(String(data.cleanupIntervalValue));
}
if (data.cleanupIntervalUnit) {
setCleanupUnit(data.cleanupIntervalUnit);
}
})
.catch(() => toast.error("Settings yüklenemedi"))
.finally(() => setLoading(false));
}, []);
const handleCopy = async (value: string, label: string) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
if (!ok) throw new Error("copy failed");
}
toast.success(`${label} kopyalandı`);
} catch {
toast.error(`${label} kopyalanamadı`);
@@ -64,6 +99,36 @@ export function SettingsPage() {
}
};
const handleSaveCleanup = async () => {
const value = Number(cleanupValue);
if (!value || Number.isNaN(value) || value < 1) {
toast.error("Geçerli bir periyot girin");
return;
}
setSavingCleanup(true);
try {
const data = await saveCleanupInterval(value, cleanupUnit);
setSettings((prev) => (prev ? { ...prev, ...data } : data));
toast.success("Temizlik periyodu kaydedildi");
} catch {
toast.error("Periyot kaydedilemedi");
} finally {
setSavingCleanup(false);
}
};
const handleCleanupImages = async () => {
setCleaning(true);
try {
await cleanupImages();
toast.success("Kullanılmayan image'lar temizlendi");
} catch {
toast.error("Temizlik başarısız");
} finally {
setCleaning(false);
}
};
if (loading) {
return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
@@ -160,6 +225,58 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Image Temizliği</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<div className="space-y-2">
<Label htmlFor="cleanupValue">Temizlik Periyodu</Label>
<div className="flex items-center gap-2">
<Input
id="cleanupValue"
type="number"
min="1"
value={cleanupValue}
onChange={(e) => setCleanupValue(e.target.value)}
className="bg-white"
/>
<Select value={cleanupUnit} onValueChange={(value) => setCleanupUnit(value as typeof cleanupUnit)}>
<SelectTrigger className="w-32">
<SelectValue placeholder="Birim" />
</SelectTrigger>
<SelectContent>
<SelectItem value="saat">saat</SelectItem>
<SelectItem value="gün">gün</SelectItem>
<SelectItem value="hafta">hafta</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<Button
variant="outline"
onClick={handleSaveCleanup}
disabled={savingCleanup}
className="gap-2 bg-white text-foreground hover:bg-muted"
>
<FontAwesomeIcon icon={faFloppyDisk} className="h-4 w-4" />
Kaydet
</Button>
<Button
onClick={handleCleanupImages}
disabled={cleaning}
className="gap-2 bg-black text-white hover:bg-black/90"
>
<FontAwesomeIcon icon={faBroom} className="h-4 w-4" />
Clean Cache Images
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

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

View File

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

View File

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