Compare commits
20 Commits
2ad6431a28
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 719ae4044e | |||
| e2b9f19800 | |||
| 064a04d898 | |||
| 1f90ce54d4 | |||
| b04ac03739 | |||
| a117275efe | |||
| 003ddfcbd1 | |||
| 535b5cbdc2 | |||
| 2ff3fb6ee6 | |||
| 0092c28571 | |||
| fd020bd9d8 | |||
| e7a5690d98 | |||
| a87baa653a | |||
| aa12881c4b | |||
| a40d07917b | |||
| f8d22cc082 | |||
| b6f6dcdff7 | |||
| a43042fac1 | |||
| 2393078933 | |||
| 0ce8559f51 |
14
.env.example
14
.env.example
@@ -1,3 +1,14 @@
|
|||||||
|
# Backend Environment
|
||||||
|
PORT=4000
|
||||||
|
MONGO_URI=mongodb://mongo:27017/wisecoltci
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=supersecret
|
||||||
|
JWT_SECRET=change-me
|
||||||
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# Frontend Environment
|
||||||
|
VITE_API_URL=http://localhost:4000/api
|
||||||
|
|
||||||
# ---------------------------------- CLAUDE API SETTINGS ---------------------------------- #
|
# ---------------------------------- CLAUDE API SETTINGS ---------------------------------- #
|
||||||
# === Claude API Config ===
|
# === Claude API Config ===
|
||||||
API_KEY_LITE="your-lite-key"
|
API_KEY_LITE="your-lite-key"
|
||||||
@@ -7,6 +18,3 @@ ACTIVE_KEY=lite
|
|||||||
# === Anthropic API Settings ===
|
# === Anthropic API Settings ===
|
||||||
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
|
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
|
||||||
ANTHROPIC_MODEL="glm-4.7"
|
ANTHROPIC_MODEL="glm-4.7"
|
||||||
|
|
||||||
# Host üzerinde projelerin bulunduğu dizin (compose volume için, zorunludur)
|
|
||||||
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ dist
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
test-runs
|
test-runs
|
||||||
backend/test-runs
|
backend/test-runs
|
||||||
|
deployments/
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
- **Log Akışı**: Gerçek zamanlı test loglarının izlenmesi
|
- **Log Akışı**: Gerçek zamanlı test loglarının izlenmesi
|
||||||
|
|
||||||
### 🚀 Deployment Yönetimi
|
### 🚀 Deployment Yönetimi
|
||||||
- **Root Tarama**: `DEPLOYMENTS_ROOT_HOST` altında compose dosyası olan projeleri otomatik bulma
|
- **Repo Bazlı Kurulum**: Repo URL ile proje oluşturma ve deploy klasörünü otomatik oluşturma
|
||||||
- **Webhook Tetikleme**: Gitea push event ile otomatik deploy
|
- **Webhook Tetikleme**: Gitea push event ile otomatik deploy
|
||||||
- **Branch Seçimi**: Repo URL girince branch listesi alınır ve seçim yapılır
|
- **Branch Seçimi**: Repo URL girince branch listesi alınır ve seçim yapılır
|
||||||
- **Deploy Geçmişi**: Her deploy için log ve süre kaydı
|
- **Deploy Geçmişi**: Her deploy için log ve süre kaydı
|
||||||
@@ -223,8 +223,8 @@ docker compose up -d --build
|
|||||||
|
|
||||||
### Deployment Yönetimi
|
### Deployment Yönetimi
|
||||||
1. **Deployments** sayfasına gidin
|
1. **Deployments** sayfasına gidin
|
||||||
2. **New Deployment** ile root altında taranan projeyi seçin
|
2. **New Deployment** ile Repo URL girin
|
||||||
3. Repo URL + Branch + Compose dosyasını girin
|
3. Branch ve Compose dosyasını seçin
|
||||||
4. Kaydettikten sonra **Webhook URL**’i Gitea’da web istemci olarak tanımlayın
|
4. Kaydettikten sonra **Webhook URL**’i Gitea’da web istemci olarak tanımlayın
|
||||||
|
|
||||||
#### Webhook Ayarları (Gitea)
|
#### Webhook Ayarları (Gitea)
|
||||||
@@ -251,7 +251,7 @@ docker compose up -d --build
|
|||||||
### 📖 API Referansı
|
### 📖 API Referansı
|
||||||
- **Authentication API'leri**: `/auth/login`, `/auth/me`
|
- **Authentication API'leri**: `/auth/login`, `/auth/me`
|
||||||
- **Test Yönetim API'leri**: CRUD operasyonları, manuel çalıştırma
|
- **Test Yönetim API'leri**: CRUD operasyonları, manuel çalıştırma
|
||||||
- **Deployment API'leri**: `/deployments`, `/deployments/:id`, `/deployments/scan`, `/deployments/branches`
|
- **Deployment API'leri**: `/deployments`, `/deployments/:id`, `/deployments/branches`, `/deployments/compose-files`
|
||||||
- **Webhook Endpoint**: `/api/deployments/webhook/:token`
|
- **Webhook Endpoint**: `/api/deployments/webhook/:token`
|
||||||
- **WebSocket Olayları**: Real-time iletişim ve durum güncellemeleri
|
- **WebSocket Olayları**: Real-time iletişim ve durum güncellemeleri
|
||||||
- **Endpoint Detayları**: Her endpoint için istek/yanıt formatları
|
- **Endpoint Detayları**: Her endpoint için istek/yanıt formatları
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
PORT=4000
|
|
||||||
# Prod için zorunlu Mongo bağlantısı
|
|
||||||
# Örnek: mongodb://<APP_USER>:<APP_PASS>@<HOST>:27017/wisecoltci?authSource=wisecoltci
|
|
||||||
MONGO_URI=mongodb://app:change-me@mongo-host:27017/wisecoltci?authSource=wisecoltci
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_PASSWORD=supersecret
|
|
||||||
JWT_SECRET=change-me
|
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
|
||||||
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ export const config = {
|
|||||||
adminPassword: process.env.ADMIN_PASSWORD || "password",
|
adminPassword: process.env.ADMIN_PASSWORD || "password",
|
||||||
jwtSecret: process.env.JWT_SECRET || "changeme",
|
jwtSecret: process.env.JWT_SECRET || "changeme",
|
||||||
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173",
|
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173",
|
||||||
deploymentsRoot: "/workspace"
|
deploymentsRoot: process.env.DEPLOYMENTS_ROOT || path.join(process.cwd(), "deployments")
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!config.jwtSecret) {
|
if (!config.jwtSecret) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import webhookRoutes from "./routes/webhooks.js";
|
|||||||
import { config } from "./config/env.js";
|
import { config } from "./config/env.js";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import { jobService } from "./services/jobService.js";
|
import { jobService } from "./services/jobService.js";
|
||||||
|
import { deploymentService } from "./services/deploymentService.js";
|
||||||
|
import { DeploymentProject } from "./models/deploymentProject.js";
|
||||||
import { Job } from "./models/job.js";
|
import { Job } from "./models/job.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -42,6 +44,7 @@ app.use("/api/settings", settingsRoutes);
|
|||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
const io = new Server(server, {
|
const io = new Server(server, {
|
||||||
|
path: "/api/socket.io",
|
||||||
cors: {
|
cors: {
|
||||||
origin: config.clientOrigin,
|
origin: config.clientOrigin,
|
||||||
methods: ["GET", "POST"]
|
methods: ["GET", "POST"]
|
||||||
@@ -49,6 +52,7 @@ const io = new Server(server, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
jobService.setSocket(io);
|
jobService.setSocket(io);
|
||||||
|
deploymentService.setSocket(io);
|
||||||
|
|
||||||
io.use((socket, next) => {
|
io.use((socket, next) => {
|
||||||
const token = socket.handshake.auth?.token as string | undefined;
|
const token = socket.handshake.auth?.token as string | undefined;
|
||||||
@@ -93,13 +97,40 @@ io.on("connection", (socket) => {
|
|||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
socket.leave(`job:${jobId}`);
|
socket.leave(`job:${jobId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on("deployment:subscribe", async ({ deploymentId }: { deploymentId: string }) => {
|
||||||
|
if (!deploymentId) return;
|
||||||
|
socket.join(`deployment:${deploymentId}`);
|
||||||
|
try {
|
||||||
|
const deployment = await DeploymentProject.findById(deploymentId);
|
||||||
|
if (deployment) {
|
||||||
|
socket.emit("deployment:status", {
|
||||||
|
deploymentId,
|
||||||
|
status: deployment.lastStatus,
|
||||||
|
lastRunAt: deployment.lastDeployAt,
|
||||||
|
lastMessage: deployment.lastMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sessizce geç
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("deployment:unsubscribe", ({ deploymentId }: { deploymentId: string }) => {
|
||||||
|
if (!deploymentId) return;
|
||||||
|
socket.leave(`deployment:${deploymentId}`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
try {
|
try {
|
||||||
await mongoose.connect(config.mongoUri);
|
await mongoose.connect(config.mongoUri);
|
||||||
console.log("MongoDB'ye bağlanıldı");
|
console.log("MongoDB'ye bağlanıldı");
|
||||||
|
await deploymentService.ensureSettings();
|
||||||
|
await jobService.bootstrapFromFilesystem();
|
||||||
await jobService.bootstrap();
|
await jobService.bootstrap();
|
||||||
|
await deploymentService.normalizeExistingCommitMessages();
|
||||||
|
await deploymentService.bootstrapFromFilesystem();
|
||||||
|
|
||||||
server.listen(config.port, () => {
|
server.listen(config.port, () => {
|
||||||
console.log(`Sunucu ${config.port} portunda çalışıyor`);
|
console.log(`Sunucu ${config.port} portunda çalışıyor`);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface DeploymentProjectDocument extends Document {
|
|||||||
webhookToken: string;
|
webhookToken: string;
|
||||||
env: DeploymentEnv;
|
env: DeploymentEnv;
|
||||||
port?: number;
|
port?: number;
|
||||||
|
envContent?: string;
|
||||||
|
envExampleName?: string;
|
||||||
lastDeployAt?: Date;
|
lastDeployAt?: Date;
|
||||||
lastStatus: DeploymentStatus;
|
lastStatus: DeploymentStatus;
|
||||||
lastMessage?: string;
|
lastMessage?: string;
|
||||||
@@ -24,7 +26,7 @@ const DeploymentProjectSchema = new Schema<DeploymentProjectDocument>(
|
|||||||
{
|
{
|
||||||
name: { type: String, required: true, trim: true },
|
name: { type: String, required: true, trim: true },
|
||||||
rootPath: { type: String, required: true, trim: true },
|
rootPath: { type: String, required: true, trim: true },
|
||||||
repoUrl: { type: String, required: true, trim: true },
|
repoUrl: { type: String, required: true, trim: true, unique: true, index: true },
|
||||||
branch: { type: String, required: true, trim: true },
|
branch: { type: String, required: true, trim: true },
|
||||||
composeFile: {
|
composeFile: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -34,6 +36,8 @@ const DeploymentProjectSchema = new Schema<DeploymentProjectDocument>(
|
|||||||
webhookToken: { type: String, required: true, unique: true, index: true },
|
webhookToken: { type: String, required: true, unique: true, index: true },
|
||||||
env: { type: String, required: true, enum: ["dev", "prod"] },
|
env: { type: String, required: true, enum: ["dev", "prod"] },
|
||||||
port: { type: Number },
|
port: { type: Number },
|
||||||
|
envContent: { type: String },
|
||||||
|
envExampleName: { type: String },
|
||||||
lastDeployAt: { type: Date },
|
lastDeployAt: { type: Date },
|
||||||
lastStatus: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" },
|
lastStatus: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" },
|
||||||
lastMessage: { type: String }
|
lastMessage: { type: String }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import mongoose, { Schema, Document } from "mongoose";
|
|||||||
export interface SettingsDocument extends Document {
|
export interface SettingsDocument extends Document {
|
||||||
webhookToken: string;
|
webhookToken: string;
|
||||||
webhookSecret: string;
|
webhookSecret: string;
|
||||||
|
cleanupIntervalValue?: number;
|
||||||
|
cleanupIntervalUnit?: "saat" | "gün" | "hafta";
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -10,7 +12,9 @@ export interface SettingsDocument extends Document {
|
|||||||
const SettingsSchema = new Schema<SettingsDocument>(
|
const SettingsSchema = new Schema<SettingsDocument>(
|
||||||
{
|
{
|
||||||
webhookToken: { type: String, required: true },
|
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 }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { authMiddleware } from "../middleware/authMiddleware.js";
|
import { authMiddleware } from "../middleware/authMiddleware.js";
|
||||||
import { deploymentService } from "../services/deploymentService.js";
|
import { deploymentService } from "../services/deploymentService.js";
|
||||||
import { DeploymentProject } from "../models/deploymentProject.js";
|
import { DeploymentProject } from "../models/deploymentProject.js";
|
||||||
import { DeploymentRun } from "../models/deploymentRun.js";
|
import { DeploymentRun } from "../models/deploymentRun.js";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -39,17 +39,6 @@ router.get("/:id/favicon", async (req, res) => {
|
|||||||
return res.status(404).end();
|
return res.status(404).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/scan", async (req, res) => {
|
|
||||||
authMiddleware(req, res, async () => {
|
|
||||||
try {
|
|
||||||
const candidates = await deploymentService.scanRoot();
|
|
||||||
return res.json(candidates);
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).json({ message: "Root taraması yapılamadı" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/branches", async (req, res) => {
|
router.get("/branches", async (req, res) => {
|
||||||
authMiddleware(req, res, async () => {
|
authMiddleware(req, res, async () => {
|
||||||
const repoUrl = req.query.repoUrl as string | undefined;
|
const repoUrl = req.query.repoUrl as string | undefined;
|
||||||
@@ -65,8 +54,44 @@ router.get("/branches", async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/compose-files", 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 files = await deploymentService.listRemoteComposeFiles(repoUrl, branch);
|
||||||
|
return res.json({ files });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).json({ message: "Compose listesi alınamadı", error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/env-examples", async (req, res) => {
|
||||||
|
authMiddleware(req, res, async () => {
|
||||||
|
const repoUrl = req.query.repoUrl as string | undefined;
|
||||||
|
const branch = req.query.branch as string | undefined;
|
||||||
|
if (!repoUrl || !branch) {
|
||||||
|
return res.status(400).json({ message: "repoUrl ve branch gerekli" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const examples = await deploymentService.listRemoteEnvExamples(repoUrl, branch);
|
||||||
|
return res.json({ examples });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).json({ message: "Env example alınamadı", error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/metrics/summary", async (req, res) => {
|
router.get("/metrics/summary", async (req, res) => {
|
||||||
authMiddleware(req, res, async () => {
|
authMiddleware(req, res, async () => {
|
||||||
|
const deploymentCount = await DeploymentProject.countDocuments();
|
||||||
|
if (deploymentCount === 0) {
|
||||||
|
await deploymentService.bootstrapFromFilesystem();
|
||||||
|
}
|
||||||
const since = new Date();
|
const since = new Date();
|
||||||
since.setDate(since.getDate() - 7);
|
since.setDate(since.getDate() - 7);
|
||||||
|
|
||||||
@@ -103,7 +128,11 @@ router.get("/metrics/summary", async (req, res) => {
|
|||||||
|
|
||||||
router.get("/", async (_req, res) => {
|
router.get("/", async (_req, res) => {
|
||||||
authMiddleware(_req, res, async () => {
|
authMiddleware(_req, res, async () => {
|
||||||
const projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean();
|
let projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean();
|
||||||
|
if (projects.length === 0) {
|
||||||
|
await deploymentService.bootstrapFromFilesystem();
|
||||||
|
projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean();
|
||||||
|
}
|
||||||
return res.json(projects);
|
return res.json(projects);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -123,19 +152,23 @@ router.get("/:id", async (req, res) => {
|
|||||||
|
|
||||||
router.post("/", async (req, res) => {
|
router.post("/", async (req, res) => {
|
||||||
authMiddleware(req, res, async () => {
|
authMiddleware(req, res, async () => {
|
||||||
const { name, rootPath, repoUrl, branch, composeFile, port } = req.body;
|
const { name, repoUrl, branch, composeFile, port, envContent, envExampleName } = req.body;
|
||||||
if (!name || !rootPath || !repoUrl || !branch || !composeFile) {
|
if (!name || !repoUrl || !branch || !composeFile) {
|
||||||
return res.status(400).json({ message: "Tüm alanlar gerekli" });
|
return res.status(400).json({ message: "Tüm alanlar gerekli" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const created = await deploymentService.createProject({
|
const created = await deploymentService.createProject({
|
||||||
name,
|
name,
|
||||||
rootPath,
|
|
||||||
repoUrl,
|
repoUrl,
|
||||||
branch,
|
branch,
|
||||||
composeFile,
|
composeFile,
|
||||||
port
|
port,
|
||||||
|
envContent,
|
||||||
|
envExampleName
|
||||||
});
|
});
|
||||||
|
deploymentService
|
||||||
|
.runDeployment(created._id.toString(), { message: "First deployment" })
|
||||||
|
.catch(() => undefined);
|
||||||
return res.status(201).json(created);
|
return res.status(201).json(created);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message });
|
return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message });
|
||||||
@@ -146,7 +179,7 @@ router.post("/", async (req, res) => {
|
|||||||
router.put("/:id", async (req, res) => {
|
router.put("/:id", async (req, res) => {
|
||||||
authMiddleware(req, res, async () => {
|
authMiddleware(req, res, async () => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, repoUrl, branch, composeFile, port } = req.body;
|
const { name, repoUrl, branch, composeFile, port, envContent, envExampleName } = req.body;
|
||||||
if (!name || !repoUrl || !branch || !composeFile) {
|
if (!name || !repoUrl || !branch || !composeFile) {
|
||||||
return res.status(400).json({ message: "Tüm alanlar gerekli" });
|
return res.status(400).json({ message: "Tüm alanlar gerekli" });
|
||||||
}
|
}
|
||||||
@@ -156,7 +189,9 @@ router.put("/:id", async (req, res) => {
|
|||||||
repoUrl,
|
repoUrl,
|
||||||
branch,
|
branch,
|
||||||
composeFile,
|
composeFile,
|
||||||
port
|
port,
|
||||||
|
envContent,
|
||||||
|
envExampleName
|
||||||
});
|
});
|
||||||
if (!updated) return res.status(404).json({ message: "Deployment bulunamadı" });
|
if (!updated) return res.status(404).json({ message: "Deployment bulunamadı" });
|
||||||
return res.json(updated);
|
return res.json(updated);
|
||||||
@@ -170,9 +205,12 @@ router.delete("/:id", async (req, res) => {
|
|||||||
authMiddleware(req, res, async () => {
|
authMiddleware(req, res, async () => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
try {
|
try {
|
||||||
const deleted = await DeploymentProject.findByIdAndDelete(id);
|
const project = await DeploymentProject.findById(id);
|
||||||
if (!deleted) return res.status(404).json({ message: "Deployment bulunamadı" });
|
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
|
||||||
|
await deploymentService.cleanupProjectResources(project);
|
||||||
|
await DeploymentProject.findByIdAndDelete(id);
|
||||||
await DeploymentRun.deleteMany({ project: id });
|
await DeploymentRun.deleteMany({ project: id });
|
||||||
|
await fs.promises.rm(project.rootPath, { recursive: true, force: true });
|
||||||
return res.json({ success: true });
|
return res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message });
|
return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message });
|
||||||
@@ -185,7 +223,25 @@ router.post("/:id/run", async (req, res) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const project = await DeploymentProject.findById(id);
|
const project = await DeploymentProject.findById(id);
|
||||||
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
|
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
|
||||||
deploymentService.runDeployment(id).catch(() => undefined);
|
const rawMessage = typeof req.body?.message === "string" ? req.body.message.trim() : "";
|
||||||
|
const message = rawMessage || "manual deploy trigger";
|
||||||
|
deploymentService
|
||||||
|
.runDeployment(id, { message })
|
||||||
|
.catch(() => undefined);
|
||||||
|
return res.json({ queued: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/:id/restart", async (req, res) => {
|
||||||
|
authMiddleware(req, res, async () => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const project = await DeploymentProject.findById(id);
|
||||||
|
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
|
||||||
|
const rawMessage = typeof req.body?.message === "string" ? req.body.message.trim() : "";
|
||||||
|
const message = rawMessage || "restart";
|
||||||
|
deploymentService
|
||||||
|
.restartDeployment(id, { message })
|
||||||
|
.catch(() => undefined);
|
||||||
return res.json({ queued: true });
|
return res.json({ queued: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ const router = Router();
|
|||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
router.get("/", async (_req, res) => {
|
router.get("/", async (_req, res) => {
|
||||||
const jobs = await Job.find().sort({ createdAt: -1 }).lean();
|
let jobs = await Job.find().sort({ createdAt: -1 }).lean();
|
||||||
|
if (jobs.length === 0) {
|
||||||
|
await jobService.bootstrapFromFilesystem();
|
||||||
|
jobs = await Job.find().sort({ createdAt: -1 }).lean();
|
||||||
|
}
|
||||||
const counts = await JobRun.aggregate([
|
const counts = await JobRun.aggregate([
|
||||||
{ $group: { _id: "$job", runCount: { $sum: 1 } } }
|
{ $group: { _id: "$job", runCount: { $sum: 1 } } }
|
||||||
]);
|
]);
|
||||||
@@ -26,6 +30,10 @@ router.get("/", async (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get("/metrics/summary", async (_req, res) => {
|
router.get("/metrics/summary", async (_req, res) => {
|
||||||
|
const jobCount = await Job.countDocuments();
|
||||||
|
if (jobCount === 0) {
|
||||||
|
await jobService.bootstrapFromFilesystem();
|
||||||
|
}
|
||||||
const since = new Date();
|
const since = new Date();
|
||||||
since.setDate(since.getDate() - 7);
|
since.setDate(since.getDate() - 7);
|
||||||
|
|
||||||
@@ -87,6 +95,7 @@ router.post("/", async (req, res) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit });
|
const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit });
|
||||||
|
await jobService.persistMetadata(job);
|
||||||
jobService.scheduleJob(job);
|
jobService.scheduleJob(job);
|
||||||
// Yeni job oluşturulduğunda ilk test otomatik tetiklensin
|
// Yeni job oluşturulduğunda ilk test otomatik tetiklensin
|
||||||
jobService.runJob(job._id.toString()).catch(() => undefined);
|
jobService.runJob(job._id.toString()).catch(() => undefined);
|
||||||
@@ -106,6 +115,7 @@ router.put("/:id", async (req, res) => {
|
|||||||
{ new: true, runValidators: true }
|
{ new: true, runValidators: true }
|
||||||
);
|
);
|
||||||
if (!job) return res.status(404).json({ message: "Job bulunamadı" });
|
if (!job) return res.status(404).json({ message: "Job bulunamadı" });
|
||||||
|
await jobService.persistMetadata(job);
|
||||||
jobService.scheduleJob(job);
|
jobService.scheduleJob(job);
|
||||||
return res.json(job);
|
return res.json(job);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ router.get("/", async (_req, res) => {
|
|||||||
return res.json({
|
return res.json({
|
||||||
webhookToken: settings.webhookToken,
|
webhookToken: settings.webhookToken,
|
||||||
webhookSecret: settings.webhookSecret,
|
webhookSecret: settings.webhookSecret,
|
||||||
|
cleanupIntervalValue: settings.cleanupIntervalValue,
|
||||||
|
cleanupIntervalUnit: settings.cleanupIntervalUnit,
|
||||||
updatedAt: settings.updatedAt
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router, Request } from "express";
|
import { Router, Request } from "express";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { deploymentService } from "../services/deploymentService.js";
|
import { deploymentService, normalizeCommitMessage } from "../services/deploymentService.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -18,6 +18,12 @@ function verifySignature(rawBody: Buffer, secret: string, signature: string) {
|
|||||||
return crypto.timingSafeEqual(Buffer.from(cleaned), Buffer.from(expected));
|
return crypto.timingSafeEqual(Buffer.from(cleaned), Buffer.from(expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBranch(value: string | undefined) {
|
||||||
|
const raw = (value || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
return raw.startsWith("refs/heads/") ? raw.replace("refs/heads/", "") : raw;
|
||||||
|
}
|
||||||
|
|
||||||
router.post("/api/deployments/webhook/:token", async (req, res) => {
|
router.post("/api/deployments/webhook/:token", async (req, res) => {
|
||||||
const { token } = req.params;
|
const { token } = req.params;
|
||||||
const settings = await deploymentService.ensureSettings();
|
const settings = await deploymentService.ensureSettings();
|
||||||
@@ -46,14 +52,16 @@ router.post("/api/deployments/webhook/:token", async (req, res) => {
|
|||||||
|
|
||||||
const payload = req.body as { ref?: string; head_commit?: { message?: string }; commits?: Array<{ message?: string }> };
|
const payload = req.body as { ref?: string; head_commit?: { message?: string }; commits?: Array<{ message?: string }> };
|
||||||
const ref = payload?.ref || "";
|
const ref = payload?.ref || "";
|
||||||
const branch = ref.startsWith("refs/heads/") ? ref.replace("refs/heads/", "") : ref;
|
const branch = normalizeBranch(ref);
|
||||||
const commitMessage =
|
const commitMessageRaw =
|
||||||
payload?.head_commit?.message || payload?.commits?.[payload.commits.length - 1]?.message;
|
payload?.head_commit?.message || payload?.commits?.[payload.commits.length - 1]?.message;
|
||||||
|
const commitMessage = normalizeCommitMessage(commitMessageRaw);
|
||||||
|
|
||||||
const project = await deploymentService.findByWebhookToken(token);
|
const project = await deploymentService.findByWebhookToken(token);
|
||||||
if (!project) return res.status(404).json({ message: "Deployment bulunamadi" });
|
if (!project) return res.status(404).json({ message: "Deployment bulunamadi" });
|
||||||
|
|
||||||
if (branch && branch !== project.branch) {
|
const projectBranch = normalizeBranch(project.branch);
|
||||||
|
if (projectBranch && projectBranch !== "*" && branch && branch !== projectBranch) {
|
||||||
return res.json({ ignored: true });
|
return res.json({ ignored: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
|
import { Server } from "socket.io";
|
||||||
import { config } from "../config/env.js";
|
import { config } from "../config/env.js";
|
||||||
import {
|
import {
|
||||||
DeploymentProject,
|
DeploymentProject,
|
||||||
@@ -9,19 +10,204 @@ import {
|
|||||||
ComposeFile,
|
ComposeFile,
|
||||||
DeploymentEnv
|
DeploymentEnv
|
||||||
} from "../models/deploymentProject.js";
|
} from "../models/deploymentProject.js";
|
||||||
import { DeploymentRun } from "../models/deploymentRun.js";
|
import { DeploymentRun, DeploymentRunDocument } from "../models/deploymentRun.js";
|
||||||
import { Settings } from "../models/settings.js";
|
import { Settings } from "../models/settings.js";
|
||||||
|
|
||||||
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
|
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
|
||||||
|
|
||||||
function normalizeRoot(rootPath: string) {
|
const deploymentsRoot = config.deploymentsRoot;
|
||||||
return path.resolve(rootPath);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWithinRoot(rootPath: string, targetPath: string) {
|
type DeploymentMetadata = {
|
||||||
const resolvedRoot = normalizeRoot(rootPath);
|
name: string;
|
||||||
const resolvedTarget = path.resolve(targetPath);
|
repoUrl: string;
|
||||||
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`);
|
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
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\.git$/i, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRepoUrl(value: string) {
|
||||||
|
return value.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateWebhookToken() {
|
function generateWebhookToken() {
|
||||||
@@ -115,11 +301,37 @@ async function ensureRepo(project: DeploymentProjectDocument, onData: (line: str
|
|||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
const entries = await fs.promises.readdir(repoDir);
|
const entries = await fs.promises.readdir(repoDir);
|
||||||
if (entries.length > 0) {
|
const allowed = new Set<string>([metadataFileName, ".env", ".env.local", runsDirName]);
|
||||||
|
const blocking = entries.filter((name) => !allowed.has(name));
|
||||||
|
if (blocking.length > 0) {
|
||||||
throw new Error("Repo klasoru git olmayan dosyalar iceriyor");
|
throw new Error("Repo klasoru git olmayan dosyalar iceriyor");
|
||||||
}
|
}
|
||||||
|
let envBackup: string | null = null;
|
||||||
|
const envPath = path.join(repoDir, ".env");
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envBackup = await fs.promises.readFile(envPath, "utf8");
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
entries
|
||||||
|
.filter((name) => allowed.has(name))
|
||||||
|
.map((name) => fs.promises.rm(path.join(repoDir, name), { force: true }))
|
||||||
|
);
|
||||||
onData(`Repo klonlanıyor: ${project.repoUrl}`);
|
onData(`Repo klonlanıyor: ${project.repoUrl}`);
|
||||||
await runCommand(`git clone --branch ${project.branch} ${project.repoUrl} .`, repoDir, onData);
|
await runCommand(`git clone --branch ${project.branch} ${project.repoUrl} .`, repoDir, onData);
|
||||||
|
if (envBackup) {
|
||||||
|
await fs.promises.writeFile(envPath, envBackup, "utf8");
|
||||||
|
}
|
||||||
|
await writeMetadata(repoDir, {
|
||||||
|
name: project.name,
|
||||||
|
repoUrl: project.repoUrl,
|
||||||
|
branch: project.branch,
|
||||||
|
composeFile: project.composeFile,
|
||||||
|
webhookToken: project.webhookToken,
|
||||||
|
env: project.env,
|
||||||
|
port: project.port,
|
||||||
|
envContent: project.envContent,
|
||||||
|
envExampleName: project.envExampleName
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
onData("Repo güncelleniyor (git fetch/pull)...");
|
onData("Repo güncelleniyor (git fetch/pull)...");
|
||||||
await runCommand(`git fetch origin ${project.branch}`, repoDir, onData);
|
await runCommand(`git fetch origin ${project.branch}`, repoDir, onData);
|
||||||
@@ -132,10 +344,7 @@ async function ensureRepo(project: DeploymentProjectDocument, onData: (line: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCompose(
|
async function runCompose(project: DeploymentProjectDocument, onData: (line: string) => void) {
|
||||||
project: DeploymentProjectDocument,
|
|
||||||
onData: (line: string) => void
|
|
||||||
) {
|
|
||||||
const composePath = path.join(project.rootPath, project.composeFile);
|
const composePath = path.join(project.rootPath, project.composeFile);
|
||||||
if (!fs.existsSync(composePath)) {
|
if (!fs.existsSync(composePath)) {
|
||||||
throw new Error("Compose dosyası bulunamadı");
|
throw new Error("Compose dosyası bulunamadı");
|
||||||
@@ -152,30 +361,39 @@ async function runCompose(
|
|||||||
|
|
||||||
class DeploymentService {
|
class DeploymentService {
|
||||||
private running: Map<string, boolean> = new Map();
|
private running: Map<string, boolean> = new Map();
|
||||||
|
private io: Server | null = null;
|
||||||
|
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
async scanRoot() {
|
setSocket(io: Server) {
|
||||||
const rootPath = normalizeRoot(config.deploymentsRoot);
|
this.io = io;
|
||||||
if (!fs.existsSync(rootPath)) {
|
}
|
||||||
throw new Error("Deployments root bulunamadı");
|
|
||||||
}
|
|
||||||
const entries = await fs.promises.readdir(rootPath, { withFileTypes: true });
|
|
||||||
const candidates = [];
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
private async emitStatus(deploymentId: string, payload: Partial<DeploymentProjectDocument>) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!this.io) return;
|
||||||
if (entry.name.startsWith(".")) continue;
|
const runCount = await DeploymentRun.countDocuments({ project: deploymentId });
|
||||||
const folderPath = path.join(rootPath, entry.name);
|
const body = {
|
||||||
const available = composeFileCandidates.filter((file) =>
|
deploymentId,
|
||||||
fs.existsSync(path.join(folderPath, file))
|
status: payload.lastStatus,
|
||||||
);
|
lastRunAt: payload.lastDeployAt,
|
||||||
if (available.length === 0) continue;
|
lastMessage: payload.lastMessage,
|
||||||
candidates.push({
|
runCount
|
||||||
name: entry.name,
|
};
|
||||||
rootPath: folderPath,
|
this.io.to(`deployment:${deploymentId}`).emit("deployment:status", body);
|
||||||
composeFiles: available
|
this.io.emit("deployment:status", body);
|
||||||
});
|
}
|
||||||
}
|
|
||||||
return candidates;
|
private emitLog(deploymentId: string, line: string) {
|
||||||
|
if (!this.io) return;
|
||||||
|
this.io.to(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
|
||||||
|
this.io.except(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitRun(deploymentId: string, run: DeploymentRunDocument) {
|
||||||
|
if (!this.io) return;
|
||||||
|
this.io.to(`deployment:${deploymentId}`).emit("deployment:run", {
|
||||||
|
deploymentId,
|
||||||
|
run
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listRemoteBranches(repoUrl: string) {
|
async listRemoteBranches(repoUrl: string) {
|
||||||
@@ -190,13 +408,83 @@ class DeploymentService {
|
|||||||
return branches;
|
return branches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listRemoteComposeFiles(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 available = composeFileCandidates.filter((file) =>
|
||||||
|
fs.existsSync(path.join(tmpBase, file))
|
||||||
|
);
|
||||||
|
return available;
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(tmpBase, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRemoteEnvExamples(repoUrl: string, branch: string) {
|
||||||
|
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
|
||||||
|
const tmpBase = await fs.promises.mkdtemp(path.join(deploymentsRoot, ".tmp-"));
|
||||||
|
try {
|
||||||
|
await runCommand(
|
||||||
|
`git clone --depth 1 --single-branch --branch ${branch} ${repoUrl} ${tmpBase}`,
|
||||||
|
process.cwd(),
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
const entries = await fs.promises.readdir(tmpBase, { withFileTypes: true });
|
||||||
|
const files = entries
|
||||||
|
.filter((entry) => entry.isFile())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.filter((name) => name.toLowerCase().endsWith(".env.example"));
|
||||||
|
const items = await Promise.all(
|
||||||
|
files.map(async (name) => ({
|
||||||
|
name,
|
||||||
|
content: await fs.promises.readFile(path.join(tmpBase, name), "utf8")
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return items;
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(tmpBase, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async ensureSettings() {
|
async ensureSettings() {
|
||||||
const existing = await Settings.findOne();
|
const existing = await Settings.findOne();
|
||||||
if (existing) return existing;
|
if (existing) {
|
||||||
|
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({
|
const created = await Settings.create({
|
||||||
webhookToken: generateApiToken(),
|
webhookToken: generateApiToken(),
|
||||||
webhookSecret: generateSecret()
|
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;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +492,12 @@ class DeploymentService {
|
|||||||
const settings = await this.ensureSettings();
|
const settings = await this.ensureSettings();
|
||||||
settings.webhookToken = generateApiToken();
|
settings.webhookToken = generateApiToken();
|
||||||
await settings.save();
|
await settings.save();
|
||||||
|
await writeSettingsFile({
|
||||||
|
webhookToken: settings.webhookToken,
|
||||||
|
webhookSecret: settings.webhookSecret,
|
||||||
|
cleanupIntervalValue: settings.cleanupIntervalValue,
|
||||||
|
cleanupIntervalUnit: settings.cleanupIntervalUnit
|
||||||
|
});
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,32 +505,50 @@ class DeploymentService {
|
|||||||
const settings = await this.ensureSettings();
|
const settings = await this.ensureSettings();
|
||||||
settings.webhookSecret = generateSecret();
|
settings.webhookSecret = generateSecret();
|
||||||
await settings.save();
|
await settings.save();
|
||||||
|
await writeSettingsFile({
|
||||||
|
webhookToken: settings.webhookToken,
|
||||||
|
webhookSecret: settings.webhookSecret,
|
||||||
|
cleanupIntervalValue: settings.cleanupIntervalValue,
|
||||||
|
cleanupIntervalUnit: settings.cleanupIntervalUnit
|
||||||
|
});
|
||||||
return settings;
|
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: {
|
async createProject(input: {
|
||||||
name: string;
|
name: string;
|
||||||
rootPath: string;
|
|
||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
composeFile: ComposeFile;
|
composeFile: ComposeFile;
|
||||||
port?: number;
|
port?: number;
|
||||||
|
envContent?: string;
|
||||||
|
envExampleName?: string;
|
||||||
}) {
|
}) {
|
||||||
const rootPath = path.resolve(input.rootPath);
|
const repoUrl = normalizeRepoUrl(input.repoUrl);
|
||||||
if (!isWithinRoot(config.deploymentsRoot, rootPath)) {
|
const existingRepo = await DeploymentProject.findOne({ repoUrl });
|
||||||
throw new Error("Root path deployments root dışında");
|
if (existingRepo) {
|
||||||
}
|
throw new Error("Bu repo zaten eklenmiş");
|
||||||
if (!fs.existsSync(rootPath)) {
|
|
||||||
throw new Error("Root path bulunamadı");
|
|
||||||
}
|
|
||||||
const composePath = path.join(rootPath, input.composeFile);
|
|
||||||
if (!fs.existsSync(composePath)) {
|
|
||||||
throw new Error("Compose dosyası bulunamadı");
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await DeploymentProject.findOne({ rootPath });
|
|
||||||
if (existing) {
|
|
||||||
throw new Error("Bu klasör zaten eklenmiş");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let webhookToken = generateWebhookToken();
|
let webhookToken = generateWebhookToken();
|
||||||
@@ -244,17 +556,43 @@ class DeploymentService {
|
|||||||
webhookToken = generateWebhookToken();
|
webhookToken = generateWebhookToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
|
||||||
|
const baseName = slugify(path.basename(repoUrl));
|
||||||
|
const suffix = crypto.randomBytes(3).toString("hex");
|
||||||
|
const slug = baseName ? `${baseName}-${suffix}` : `deployment-${suffix}`;
|
||||||
|
const rootPath = path.join(deploymentsRoot, slug);
|
||||||
|
await fs.promises.mkdir(rootPath, { recursive: true });
|
||||||
|
|
||||||
|
const available = await this.listRemoteComposeFiles(repoUrl, input.branch);
|
||||||
|
if (!available.includes(input.composeFile)) {
|
||||||
|
throw new Error("Compose dosyası repoda bulunamadı");
|
||||||
|
}
|
||||||
|
|
||||||
const env = deriveEnv(input.composeFile);
|
const env = deriveEnv(input.composeFile);
|
||||||
return DeploymentProject.create({
|
const created = await DeploymentProject.create({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
rootPath,
|
rootPath,
|
||||||
repoUrl: input.repoUrl,
|
repoUrl,
|
||||||
branch: input.branch,
|
branch: input.branch,
|
||||||
composeFile: input.composeFile,
|
composeFile: input.composeFile,
|
||||||
webhookToken,
|
webhookToken,
|
||||||
env,
|
env,
|
||||||
port: input.port
|
port: input.port,
|
||||||
|
envContent: input.envContent,
|
||||||
|
envExampleName: input.envExampleName
|
||||||
});
|
});
|
||||||
|
await writeMetadata(rootPath, {
|
||||||
|
name: created.name,
|
||||||
|
repoUrl: created.repoUrl,
|
||||||
|
branch: created.branch,
|
||||||
|
composeFile: created.composeFile,
|
||||||
|
webhookToken: created.webhookToken,
|
||||||
|
env: created.env,
|
||||||
|
port: created.port,
|
||||||
|
envContent: created.envContent,
|
||||||
|
envExampleName: created.envExampleName
|
||||||
|
});
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProject(
|
async updateProject(
|
||||||
@@ -265,27 +603,51 @@ class DeploymentService {
|
|||||||
branch: string;
|
branch: string;
|
||||||
composeFile: ComposeFile;
|
composeFile: ComposeFile;
|
||||||
port?: number;
|
port?: number;
|
||||||
|
envContent?: string;
|
||||||
|
envExampleName?: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const project = await DeploymentProject.findById(id);
|
const project = await DeploymentProject.findById(id);
|
||||||
if (!project) return null;
|
if (!project) return null;
|
||||||
const composePath = path.join(project.rootPath, input.composeFile);
|
const repoUrl = normalizeRepoUrl(input.repoUrl);
|
||||||
if (!fs.existsSync(composePath)) {
|
if (repoUrl !== project.repoUrl) {
|
||||||
throw new Error("Compose dosyası bulunamadı");
|
const existingRepo = await DeploymentProject.findOne({ repoUrl });
|
||||||
|
if (existingRepo && existingRepo._id.toString() !== id) {
|
||||||
|
throw new Error("Bu repo zaten eklenmiş");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const available = await this.listRemoteComposeFiles(repoUrl, input.branch);
|
||||||
|
if (!available.includes(input.composeFile)) {
|
||||||
|
throw new Error("Compose dosyası repoda bulunamadı");
|
||||||
}
|
}
|
||||||
const env = deriveEnv(input.composeFile);
|
const env = deriveEnv(input.composeFile);
|
||||||
const updated = await DeploymentProject.findByIdAndUpdate(
|
const updated = await DeploymentProject.findByIdAndUpdate(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
name: input.name,
|
name: input.name,
|
||||||
repoUrl: input.repoUrl,
|
repoUrl,
|
||||||
branch: input.branch,
|
branch: input.branch,
|
||||||
composeFile: input.composeFile,
|
composeFile: input.composeFile,
|
||||||
env,
|
env,
|
||||||
port: input.port
|
port: input.port,
|
||||||
|
envContent: input.envContent,
|
||||||
|
envExampleName: input.envExampleName
|
||||||
},
|
},
|
||||||
{ new: true, runValidators: true }
|
{ new: true, runValidators: true }
|
||||||
);
|
);
|
||||||
|
if (updated) {
|
||||||
|
await writeMetadata(updated.rootPath, {
|
||||||
|
name: updated.name,
|
||||||
|
repoUrl: updated.repoUrl,
|
||||||
|
branch: updated.branch,
|
||||||
|
composeFile: updated.composeFile,
|
||||||
|
webhookToken: updated.webhookToken,
|
||||||
|
env: updated.env,
|
||||||
|
port: updated.port,
|
||||||
|
envContent: updated.envContent,
|
||||||
|
envExampleName: updated.envExampleName
|
||||||
|
});
|
||||||
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,41 +663,61 @@ class DeploymentService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedMessage = normalizeCommitMessage(options?.message);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const runLogs: string[] = [];
|
const runLogs: string[] = [];
|
||||||
const pushLog = (line: string) => {
|
const pushLog = (line: string) => {
|
||||||
runLogs.push(line);
|
runLogs.push(line);
|
||||||
|
this.emitLog(projectId, line);
|
||||||
};
|
};
|
||||||
|
|
||||||
const runDoc = await DeploymentRun.create({
|
const runDoc = await DeploymentRun.create({
|
||||||
project: projectId,
|
project: projectId,
|
||||||
status: "running",
|
status: "running",
|
||||||
startedAt: new Date(),
|
startedAt: new Date(),
|
||||||
message: options?.message
|
message: normalizedMessage ?? options?.message
|
||||||
});
|
});
|
||||||
|
this.emitRun(projectId, runDoc);
|
||||||
|
await writeRunFile(project.rootPath, runDoc);
|
||||||
|
|
||||||
await DeploymentProject.findByIdAndUpdate(projectId, {
|
await DeploymentProject.findByIdAndUpdate(projectId, {
|
||||||
lastStatus: "running",
|
lastStatus: "running",
|
||||||
lastMessage: options?.message || "Deploy başlıyor..."
|
lastMessage: normalizedMessage ?? options?.message ?? "Deploy başlıyor..."
|
||||||
});
|
});
|
||||||
|
await this.emitStatus(projectId, {
|
||||||
|
lastStatus: "running",
|
||||||
|
lastMessage: normalizedMessage ?? options?.message ?? "Deploy başlıyor..."
|
||||||
|
} as DeploymentProjectDocument);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureRepo(project, (line) => pushLog(line));
|
await ensureRepo(project, (line) => pushLog(line));
|
||||||
|
if (project.envContent) {
|
||||||
|
await fs.promises.writeFile(path.join(project.rootPath, ".env"), project.envContent, "utf8");
|
||||||
|
pushLog(".env güncellendi");
|
||||||
|
}
|
||||||
pushLog("Deploy komutları çalıştırılıyor...");
|
pushLog("Deploy komutları çalıştırılıyor...");
|
||||||
await runCompose(project, (line) => pushLog(line));
|
await runCompose(project, (line) => pushLog(line));
|
||||||
const duration = Date.now() - startedAt;
|
const duration = Date.now() - startedAt;
|
||||||
await DeploymentProject.findByIdAndUpdate(projectId, {
|
await DeploymentProject.findByIdAndUpdate(projectId, {
|
||||||
lastStatus: "success",
|
lastStatus: "success",
|
||||||
lastDeployAt: new Date(),
|
lastDeployAt: new Date(),
|
||||||
lastMessage: options?.message || "Başarılı"
|
lastMessage: normalizedMessage ?? options?.message ?? "Başarılı"
|
||||||
});
|
});
|
||||||
|
await this.emitStatus(projectId, {
|
||||||
|
lastStatus: "success",
|
||||||
|
lastDeployAt: new Date(),
|
||||||
|
lastMessage: normalizedMessage ?? options?.message ?? "Başarılı"
|
||||||
|
} as DeploymentProjectDocument);
|
||||||
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
||||||
status: "success",
|
status: "success",
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
durationMs: duration,
|
durationMs: duration,
|
||||||
logs: runLogs,
|
logs: runLogs,
|
||||||
message: options?.message
|
message: normalizedMessage ?? options?.message
|
||||||
});
|
});
|
||||||
|
const updatedRun = await DeploymentRun.findById(runDoc._id);
|
||||||
|
if (updatedRun) this.emitRun(projectId, updatedRun);
|
||||||
|
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
|
||||||
pushLog("Deploy tamamlandı: Başarılı");
|
pushLog("Deploy tamamlandı: Başarılı");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const duration = Date.now() - startedAt;
|
const duration = Date.now() - startedAt;
|
||||||
@@ -344,22 +726,258 @@ class DeploymentService {
|
|||||||
lastDeployAt: new Date(),
|
lastDeployAt: new Date(),
|
||||||
lastMessage: (err as Error).message
|
lastMessage: (err as Error).message
|
||||||
});
|
});
|
||||||
|
await this.emitStatus(projectId, {
|
||||||
|
lastStatus: "failed",
|
||||||
|
lastDeployAt: new Date(),
|
||||||
|
lastMessage: (err as Error).message
|
||||||
|
} as DeploymentProjectDocument);
|
||||||
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
durationMs: duration,
|
durationMs: duration,
|
||||||
logs: runLogs,
|
logs: runLogs,
|
||||||
message: options?.message
|
message: normalizedMessage ?? options?.message
|
||||||
});
|
});
|
||||||
|
const updatedRun = await DeploymentRun.findById(runDoc._id);
|
||||||
|
if (updatedRun) this.emitRun(projectId, updatedRun);
|
||||||
|
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
|
||||||
pushLog(`Hata: ${(err as Error).message}`);
|
pushLog(`Hata: ${(err as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
this.running.delete(projectId);
|
this.running.delete(projectId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async restartDeployment(projectId: string, options?: { message?: string }) {
|
||||||
|
if (this.running.get(projectId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.running.set(projectId, true);
|
||||||
|
|
||||||
|
const project = await DeploymentProject.findById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
this.running.delete(projectId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedMessage = normalizeCommitMessage(options?.message);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const runLogs: string[] = [];
|
||||||
|
const pushLog = (line: string) => {
|
||||||
|
runLogs.push(line);
|
||||||
|
this.emitLog(projectId, line);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runDoc = await DeploymentRun.create({
|
||||||
|
project: projectId,
|
||||||
|
status: "running",
|
||||||
|
startedAt: new Date(),
|
||||||
|
message: normalizedMessage ?? options?.message
|
||||||
|
});
|
||||||
|
this.emitRun(projectId, runDoc);
|
||||||
|
await writeRunFile(project.rootPath, runDoc);
|
||||||
|
|
||||||
|
await DeploymentProject.findByIdAndUpdate(projectId, {
|
||||||
|
lastStatus: "running",
|
||||||
|
lastMessage: normalizedMessage ?? options?.message ?? "Restart başlıyor..."
|
||||||
|
});
|
||||||
|
await this.emitStatus(projectId, {
|
||||||
|
lastStatus: "running",
|
||||||
|
lastMessage: normalizedMessage ?? options?.message ?? "Restart başlıyor..."
|
||||||
|
} as DeploymentProjectDocument);
|
||||||
|
|
||||||
|
try {
|
||||||
|
pushLog("Restart komutları çalıştırılıyor...");
|
||||||
|
await runCompose(project, (line) => pushLog(line));
|
||||||
|
const duration = Date.now() - startedAt;
|
||||||
|
await DeploymentProject.findByIdAndUpdate(projectId, {
|
||||||
|
lastStatus: "success",
|
||||||
|
lastDeployAt: new Date(),
|
||||||
|
lastMessage: normalizedMessage ?? options?.message ?? "Restart başarılı"
|
||||||
|
});
|
||||||
|
await this.emitStatus(projectId, {
|
||||||
|
lastStatus: "success",
|
||||||
|
lastDeployAt: new Date(),
|
||||||
|
lastMessage: normalizedMessage ?? options?.message ?? "Restart başarılı"
|
||||||
|
} as DeploymentProjectDocument);
|
||||||
|
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
||||||
|
status: "success",
|
||||||
|
finishedAt: new Date(),
|
||||||
|
durationMs: duration,
|
||||||
|
logs: runLogs,
|
||||||
|
message: normalizedMessage ?? options?.message
|
||||||
|
});
|
||||||
|
const updatedRun = await DeploymentRun.findById(runDoc._id);
|
||||||
|
if (updatedRun) this.emitRun(projectId, updatedRun);
|
||||||
|
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
|
||||||
|
pushLog("Restart tamamlandı: Başarılı");
|
||||||
|
} catch (err) {
|
||||||
|
const duration = Date.now() - startedAt;
|
||||||
|
await DeploymentProject.findByIdAndUpdate(projectId, {
|
||||||
|
lastStatus: "failed",
|
||||||
|
lastDeployAt: new Date(),
|
||||||
|
lastMessage: (err as Error).message
|
||||||
|
});
|
||||||
|
await this.emitStatus(projectId, {
|
||||||
|
lastStatus: "failed",
|
||||||
|
lastDeployAt: new Date(),
|
||||||
|
lastMessage: (err as Error).message
|
||||||
|
} as DeploymentProjectDocument);
|
||||||
|
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
||||||
|
status: "failed",
|
||||||
|
finishedAt: new Date(),
|
||||||
|
durationMs: duration,
|
||||||
|
logs: runLogs,
|
||||||
|
message: normalizedMessage ?? options?.message
|
||||||
|
});
|
||||||
|
const updatedRun = await DeploymentRun.findById(runDoc._id);
|
||||||
|
if (updatedRun) this.emitRun(projectId, updatedRun);
|
||||||
|
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
|
||||||
|
pushLog(`Hata: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
this.running.delete(projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupProjectResources(project: DeploymentProjectDocument) {
|
||||||
|
const composePath = path.join(project.rootPath, project.composeFile);
|
||||||
|
if (!fs.existsSync(composePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runCommand(
|
||||||
|
`docker compose -f ${project.composeFile} down --remove-orphans -v --rmi local`,
|
||||||
|
project.rootPath,
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async findByWebhookToken(token: string) {
|
async findByWebhookToken(token: string) {
|
||||||
return DeploymentProject.findOne({ webhookToken: token });
|
return DeploymentProject.findOne({ webhookToken: token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async normalizeExistingCommitMessages() {
|
||||||
|
const projects = await DeploymentProject.find({
|
||||||
|
lastMessage: { $regex: /[\r\n]/ }
|
||||||
|
});
|
||||||
|
for (const project of projects) {
|
||||||
|
const normalized = normalizeCommitMessage(project.lastMessage);
|
||||||
|
if (normalized && normalized !== project.lastMessage) {
|
||||||
|
project.lastMessage = normalized;
|
||||||
|
await project.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runs = await DeploymentRun.find({
|
||||||
|
message: { $regex: /[\r\n]/ }
|
||||||
|
});
|
||||||
|
for (const run of runs) {
|
||||||
|
const normalized = normalizeCommitMessage(run.message);
|
||||||
|
if (normalized && normalized !== run.message) {
|
||||||
|
run.message = normalized;
|
||||||
|
await run.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async bootstrapFromFilesystem() {
|
||||||
|
const candidateRoots = [
|
||||||
|
deploymentsRoot,
|
||||||
|
path.resolve(process.cwd(), "deployments"),
|
||||||
|
path.resolve(process.cwd(), "..", "deployments"),
|
||||||
|
path.resolve(process.cwd(), "..", "..", "deployments"),
|
||||||
|
"/root/Wisecolt-CI/deployments"
|
||||||
|
];
|
||||||
|
const roots = Array.from(
|
||||||
|
new Set(candidateRoots.filter((root) => root && fs.existsSync(root)))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
const entries = await fs.promises.readdir(root, { withFileTypes: true });
|
||||||
|
const dirs = entries.filter((entry) => entry.isDirectory());
|
||||||
|
|
||||||
|
for (const entry of dirs) {
|
||||||
|
const rootPath = path.join(root, entry.name);
|
||||||
|
const existing = await DeploymentProject.findOne({ rootPath });
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
const metadata = await readMetadata(rootPath);
|
||||||
|
const repoUrlRaw = metadata?.repoUrl || (await inferRepoUrlFromGit(rootPath));
|
||||||
|
if (!repoUrlRaw) continue;
|
||||||
|
const repoUrl = normalizeRepoUrl(repoUrlRaw);
|
||||||
|
const repoExisting = await DeploymentProject.findOne({ repoUrl });
|
||||||
|
if (repoExisting) continue;
|
||||||
|
|
||||||
|
const composeFile = metadata?.composeFile || inferComposeFile(rootPath);
|
||||||
|
if (!composeFile) continue;
|
||||||
|
const branch = metadata?.branch || (await inferBranchFromGit(rootPath)) || "main";
|
||||||
|
const name = metadata?.name || inferName(repoUrl, rootPath);
|
||||||
|
|
||||||
|
let webhookToken = metadata?.webhookToken || generateWebhookToken();
|
||||||
|
while (await DeploymentProject.findOne({ webhookToken })) {
|
||||||
|
webhookToken = generateWebhookToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
let envContent = metadata?.envContent;
|
||||||
|
const envPath = path.join(rootPath, ".env");
|
||||||
|
if (!envContent && fs.existsSync(envPath)) {
|
||||||
|
envContent = await fs.promises.readFile(envPath, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const envExampleName = metadata?.envExampleName;
|
||||||
|
const env = deriveEnv(composeFile);
|
||||||
|
|
||||||
|
const created = await DeploymentProject.create({
|
||||||
|
name,
|
||||||
|
rootPath,
|
||||||
|
repoUrl,
|
||||||
|
branch,
|
||||||
|
composeFile,
|
||||||
|
webhookToken,
|
||||||
|
env,
|
||||||
|
port: metadata?.port,
|
||||||
|
envContent,
|
||||||
|
envExampleName
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeMetadata(rootPath, {
|
||||||
|
name: created.name,
|
||||||
|
repoUrl: created.repoUrl,
|
||||||
|
branch: created.branch,
|
||||||
|
composeFile: created.composeFile,
|
||||||
|
webhookToken: created.webhookToken,
|
||||||
|
env: created.env,
|
||||||
|
port: created.port,
|
||||||
|
envContent: created.envContent,
|
||||||
|
envExampleName: created.envExampleName
|
||||||
|
});
|
||||||
|
|
||||||
|
const storedRuns = await readStoredRuns(rootPath);
|
||||||
|
if (storedRuns.length > 0) {
|
||||||
|
storedRuns.sort(
|
||||||
|
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||||
|
);
|
||||||
|
await DeploymentRun.insertMany(
|
||||||
|
storedRuns.map((run) => ({
|
||||||
|
project: created._id,
|
||||||
|
status: run.status,
|
||||||
|
message: run.message,
|
||||||
|
logs: run.logs || [],
|
||||||
|
startedAt: new Date(run.startedAt),
|
||||||
|
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
|
||||||
|
durationMs: run.durationMs,
|
||||||
|
createdAt: new Date(run.createdAt),
|
||||||
|
updatedAt: new Date(run.updatedAt)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const latest = storedRuns[0];
|
||||||
|
await DeploymentProject.findByIdAndUpdate(created._id, {
|
||||||
|
lastStatus: latest.status,
|
||||||
|
lastDeployAt: new Date(latest.finishedAt || latest.startedAt),
|
||||||
|
lastMessage: latest.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deploymentService = new DeploymentService();
|
export const deploymentService = new DeploymentService();
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import path from "path";
|
|||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { Server } from "socket.io";
|
import { Server } from "socket.io";
|
||||||
import { Job, JobDocument, TimeUnit } from "../models/job.js";
|
import { Job, JobDocument, TimeUnit } from "../models/job.js";
|
||||||
import { JobRun } from "../models/jobRun.js";
|
import { JobRun, JobRunDocument } from "../models/jobRun.js";
|
||||||
|
|
||||||
const repoBaseDir = path.join(process.cwd(), "test-runs");
|
const repoBaseDir = path.join(process.cwd(), "test-runs");
|
||||||
|
const jobMetadataFileName = ".wisecolt-ci-job.json";
|
||||||
|
const jobRunsDirName = ".wisecolt-ci-job-runs";
|
||||||
|
|
||||||
function unitToMs(unit: TimeUnit) {
|
function unitToMs(unit: TimeUnit) {
|
||||||
if (unit === "dakika") return 60_000;
|
if (unit === "dakika") return 60_000;
|
||||||
@@ -17,6 +19,91 @@ function ensureDir(dir: string) {
|
|||||||
return fs.promises.mkdir(dir, { recursive: true });
|
return fs.promises.mkdir(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type JobMetadata = {
|
||||||
|
name: string;
|
||||||
|
repoUrl: string;
|
||||||
|
testCommand: string;
|
||||||
|
checkValue: number;
|
||||||
|
checkUnit: TimeUnit;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoredJobRun = {
|
||||||
|
status: "running" | "success" | "failed";
|
||||||
|
logs: string[];
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getJobDir(jobId: string) {
|
||||||
|
return path.join(repoBaseDir, jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJobRunsDir(jobDir: string) {
|
||||||
|
return path.join(jobDir, jobRunsDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJobMetadata(jobDir: string): Promise<JobMetadata | null> {
|
||||||
|
const filePath = path.join(jobDir, jobMetadataFileName);
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
try {
|
||||||
|
const raw = await fs.promises.readFile(filePath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as JobMetadata;
|
||||||
|
if (!parsed?.repoUrl || !parsed?.testCommand) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJobMetadata(jobDir: string, data: JobMetadata) {
|
||||||
|
await ensureDir(jobDir);
|
||||||
|
const filePath = path.join(jobDir, jobMetadataFileName);
|
||||||
|
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeJobRun(run: JobRunDocument) {
|
||||||
|
return {
|
||||||
|
status: run.status,
|
||||||
|
logs: run.logs || [],
|
||||||
|
startedAt: new Date(run.startedAt).toISOString(),
|
||||||
|
finishedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : undefined,
|
||||||
|
durationMs: run.durationMs,
|
||||||
|
createdAt: new Date(run.createdAt).toISOString(),
|
||||||
|
updatedAt: new Date(run.updatedAt).toISOString()
|
||||||
|
} satisfies StoredJobRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJobRunFile(jobDir: string, run: JobRunDocument) {
|
||||||
|
const dir = getJobRunsDir(jobDir);
|
||||||
|
await ensureDir(dir);
|
||||||
|
const data = serializeJobRun(run);
|
||||||
|
const name = `${new Date(data.startedAt).getTime()}-${run._id.toString()}.json`;
|
||||||
|
const filePath = path.join(dir, name);
|
||||||
|
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStoredJobRuns(jobDir: string): Promise<StoredJobRun[]> {
|
||||||
|
const dir = getJobRunsDir(jobDir);
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
const entries = await fs.promises.readdir(dir);
|
||||||
|
const items: StoredJobRun[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.endsWith(".json")) continue;
|
||||||
|
try {
|
||||||
|
const raw = await fs.promises.readFile(path.join(dir, entry), "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as StoredJobRun;
|
||||||
|
if (!parsed?.startedAt || !parsed?.status) continue;
|
||||||
|
items.push(parsed);
|
||||||
|
} catch {
|
||||||
|
// ignore invalid file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
function cleanOutput(input: string) {
|
function cleanOutput(input: string) {
|
||||||
// ANSI escape sequences temizleme
|
// ANSI escape sequences temizleme
|
||||||
return input.replace(
|
return input.replace(
|
||||||
@@ -85,8 +172,42 @@ async function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) {
|
|||||||
const exists = fs.existsSync(gitDir);
|
const exists = fs.existsSync(gitDir);
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
|
const entries = await fs.promises.readdir(repoDir);
|
||||||
|
const allowed = new Set<string>([jobMetadataFileName, jobRunsDirName]);
|
||||||
|
const blocking = entries.filter((name) => !allowed.has(name));
|
||||||
|
if (blocking.length > 0) {
|
||||||
|
throw new Error("Repo klasoru git olmayan dosyalar iceriyor");
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadataBackup: string | null = null;
|
||||||
|
const metadataPath = path.join(repoDir, jobMetadataFileName);
|
||||||
|
if (fs.existsSync(metadataPath)) {
|
||||||
|
metadataBackup = await fs.promises.readFile(metadataPath, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
let runsBackupPath: string | null = null;
|
||||||
|
const runsDir = path.join(repoDir, jobRunsDirName);
|
||||||
|
if (fs.existsSync(runsDir)) {
|
||||||
|
const tmpBase = await fs.promises.mkdtemp(path.join(repoBaseDir, ".tmp-"));
|
||||||
|
runsBackupPath = path.join(tmpBase, jobRunsDirName);
|
||||||
|
await fs.promises.rename(runsDir, runsBackupPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
entries
|
||||||
|
.filter((name) => allowed.has(name))
|
||||||
|
.map((name) => fs.promises.rm(path.join(repoDir, name), { recursive: true, force: true }))
|
||||||
|
);
|
||||||
|
|
||||||
onData(`Repo klonlanıyor: ${job.repoUrl}`);
|
onData(`Repo klonlanıyor: ${job.repoUrl}`);
|
||||||
await runCommand(`git clone ${job.repoUrl} ${repoDir}`, process.cwd(), onData);
|
await runCommand(`git clone ${job.repoUrl} ${repoDir}`, process.cwd(), onData);
|
||||||
|
|
||||||
|
if (metadataBackup) {
|
||||||
|
await fs.promises.writeFile(metadataPath, metadataBackup, "utf8");
|
||||||
|
}
|
||||||
|
if (runsBackupPath) {
|
||||||
|
await fs.promises.rename(runsBackupPath, runsDir);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
onData("Repo güncelleniyor (git pull)...");
|
onData("Repo güncelleniyor (git pull)...");
|
||||||
await runCommand("git pull", repoDir, onData);
|
await runCommand("git pull", repoDir, onData);
|
||||||
@@ -156,6 +277,7 @@ class JobService {
|
|||||||
status: "running",
|
status: "running",
|
||||||
startedAt: new Date()
|
startedAt: new Date()
|
||||||
});
|
});
|
||||||
|
await writeJobRunFile(getJobDir(jobId), runDoc);
|
||||||
|
|
||||||
await Job.findByIdAndUpdate(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." });
|
await Job.findByIdAndUpdate(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." });
|
||||||
await this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument);
|
await this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument);
|
||||||
@@ -179,6 +301,8 @@ class JobService {
|
|||||||
durationMs: duration,
|
durationMs: duration,
|
||||||
logs: runLogs
|
logs: runLogs
|
||||||
});
|
});
|
||||||
|
const updatedRun = await JobRun.findById(runDoc._id);
|
||||||
|
if (updatedRun) await writeJobRunFile(getJobDir(jobId), updatedRun);
|
||||||
await this.emitStatus(jobId, {
|
await this.emitStatus(jobId, {
|
||||||
status: "success",
|
status: "success",
|
||||||
lastRunAt: new Date(),
|
lastRunAt: new Date(),
|
||||||
@@ -199,6 +323,8 @@ class JobService {
|
|||||||
durationMs: duration,
|
durationMs: duration,
|
||||||
logs: runLogs
|
logs: runLogs
|
||||||
});
|
});
|
||||||
|
const updatedRun = await JobRun.findById(runDoc._id);
|
||||||
|
if (updatedRun) await writeJobRunFile(getJobDir(jobId), updatedRun);
|
||||||
pushLog(`Hata: ${(err as Error).message}`);
|
pushLog(`Hata: ${(err as Error).message}`);
|
||||||
await this.emitStatus(jobId, {
|
await this.emitStatus(jobId, {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
@@ -231,6 +357,78 @@ class JobService {
|
|||||||
const jobs = await Job.find();
|
const jobs = await Job.find();
|
||||||
jobs.forEach((job) => this.scheduleJob(job));
|
jobs.forEach((job) => this.scheduleJob(job));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async persistMetadata(job: JobDocument) {
|
||||||
|
await writeJobMetadata(getJobDir(job._id.toString()), {
|
||||||
|
name: job.name,
|
||||||
|
repoUrl: job.repoUrl,
|
||||||
|
testCommand: job.testCommand,
|
||||||
|
checkValue: job.checkValue,
|
||||||
|
checkUnit: job.checkUnit
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async bootstrapFromFilesystem() {
|
||||||
|
const candidateRoots = [
|
||||||
|
repoBaseDir,
|
||||||
|
path.resolve(process.cwd(), "test-runs"),
|
||||||
|
path.resolve(process.cwd(), "..", "test-runs"),
|
||||||
|
path.resolve(process.cwd(), "..", "..", "test-runs"),
|
||||||
|
"/root/Wisecolt-CI/test-runs"
|
||||||
|
];
|
||||||
|
const roots = Array.from(
|
||||||
|
new Set(candidateRoots.filter((root) => root && fs.existsSync(root)))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
const entries = await fs.promises.readdir(root, { withFileTypes: true });
|
||||||
|
const dirs = entries.filter((entry) => entry.isDirectory());
|
||||||
|
|
||||||
|
for (const entry of dirs) {
|
||||||
|
const jobDir = path.join(root, entry.name);
|
||||||
|
const metadata = await readJobMetadata(jobDir);
|
||||||
|
if (!metadata) continue;
|
||||||
|
|
||||||
|
const existing = await Job.findOne({ repoUrl: metadata.repoUrl });
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
const created = await Job.create({
|
||||||
|
name: metadata.name,
|
||||||
|
repoUrl: metadata.repoUrl,
|
||||||
|
testCommand: metadata.testCommand,
|
||||||
|
checkValue: metadata.checkValue,
|
||||||
|
checkUnit: metadata.checkUnit
|
||||||
|
});
|
||||||
|
await this.persistMetadata(created);
|
||||||
|
|
||||||
|
const storedRuns = await readStoredJobRuns(jobDir);
|
||||||
|
if (storedRuns.length > 0) {
|
||||||
|
storedRuns.sort(
|
||||||
|
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||||
|
);
|
||||||
|
await JobRun.insertMany(
|
||||||
|
storedRuns.map((run) => ({
|
||||||
|
job: created._id,
|
||||||
|
status: run.status,
|
||||||
|
logs: run.logs || [],
|
||||||
|
startedAt: new Date(run.startedAt),
|
||||||
|
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
|
||||||
|
durationMs: run.durationMs,
|
||||||
|
createdAt: new Date(run.createdAt),
|
||||||
|
updatedAt: new Date(run.updatedAt)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const latest = storedRuns[0];
|
||||||
|
await Job.findByIdAndUpdate(created._id, {
|
||||||
|
status: latest.status === "running" ? "idle" : latest.status,
|
||||||
|
lastRunAt: new Date(latest.finishedAt || latest.startedAt),
|
||||||
|
lastDurationMs: latest.durationMs,
|
||||||
|
lastMessage: latest.status === "success" ? "Başarılı" : "Hata"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const jobService = new JobService();
|
export const jobService = new JobService();
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
- ${DEPLOYMENTS_ROOT_HOST}:/workspace
|
- ${PWD}:${PWD}
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./.env
|
||||||
|
environment:
|
||||||
|
DEPLOYMENTS_ROOT: ${PWD}/deployments
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -29,7 +31,7 @@ services:
|
|||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
env_file:
|
env_file:
|
||||||
- ./frontend/.env
|
- ./.env
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ services:
|
|||||||
build: ./backend
|
build: ./backend
|
||||||
command: npm run build && npm start
|
command: npm run build && npm start
|
||||||
volumes:
|
volumes:
|
||||||
- ${DEPLOYMENTS_ROOT_HOST}:/workspace
|
- ${PWD}:${PWD}
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./.env
|
||||||
|
environment:
|
||||||
|
DEPLOYMENTS_ROOT: ${PWD}/deployments
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ services:
|
|||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
env_file:
|
env_file:
|
||||||
- ./frontend/.env
|
- ./.env
|
||||||
environment:
|
environment:
|
||||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS}
|
ALLOWED_HOSTS: ${ALLOWED_HOSTS}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
"axios": "^1.5.1",
|
"axios": "^1.5.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface DeploymentProject {
|
|||||||
webhookToken: string;
|
webhookToken: string;
|
||||||
env: DeploymentEnv;
|
env: DeploymentEnv;
|
||||||
port?: number;
|
port?: number;
|
||||||
|
envContent?: string;
|
||||||
|
envExampleName?: string;
|
||||||
lastDeployAt?: string;
|
lastDeployAt?: string;
|
||||||
lastStatus: DeploymentStatus;
|
lastStatus: DeploymentStatus;
|
||||||
lastMessage?: string;
|
lastMessage?: string;
|
||||||
@@ -54,19 +56,14 @@ export interface DeploymentMetrics {
|
|||||||
recentRuns: DeploymentRunWithProject[];
|
recentRuns: DeploymentRunWithProject[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeploymentCandidate {
|
|
||||||
name: string;
|
|
||||||
rootPath: string;
|
|
||||||
composeFiles: ComposeFile[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeploymentInput {
|
export interface DeploymentInput {
|
||||||
name: string;
|
name: string;
|
||||||
rootPath: string;
|
|
||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
composeFile: ComposeFile;
|
composeFile: ComposeFile;
|
||||||
port?: number;
|
port?: number;
|
||||||
|
envContent?: string;
|
||||||
|
envExampleName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDeployments(): Promise<DeploymentProject[]> {
|
export async function fetchDeployments(): Promise<DeploymentProject[]> {
|
||||||
@@ -81,17 +78,12 @@ export async function fetchDeploymentBranches(repoUrl: string): Promise<string[]
|
|||||||
return (data as { branches: string[] }).branches;
|
return (data as { branches: string[] }).branches;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scanDeployments(): Promise<DeploymentCandidate[]> {
|
|
||||||
const { data } = await apiClient.get("/deployments/scan");
|
|
||||||
return data as DeploymentCandidate[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createDeployment(payload: DeploymentInput): Promise<DeploymentProject> {
|
export async function createDeployment(payload: DeploymentInput): Promise<DeploymentProject> {
|
||||||
const { data } = await apiClient.post("/deployments", payload);
|
const { data } = await apiClient.post("/deployments", payload);
|
||||||
return data as DeploymentProject;
|
return data as DeploymentProject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDeployment(id: string, payload: Omit<DeploymentInput, "rootPath">) {
|
export async function updateDeployment(id: string, payload: DeploymentInput) {
|
||||||
const { data } = await apiClient.put(`/deployments/${id}`, payload);
|
const { data } = await apiClient.put(`/deployments/${id}`, payload);
|
||||||
return data as DeploymentProject;
|
return data as DeploymentProject;
|
||||||
}
|
}
|
||||||
@@ -100,8 +92,12 @@ export async function deleteDeployment(id: string): Promise<void> {
|
|||||||
await apiClient.delete(`/deployments/${id}`);
|
await apiClient.delete(`/deployments/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runDeployment(id: string): Promise<void> {
|
export async function runDeployment(id: string, message?: string): Promise<void> {
|
||||||
await apiClient.post(`/deployments/${id}/run`);
|
await apiClient.post(`/deployments/${id}/run`, message ? { message } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartDeployment(id: string, message?: string): Promise<void> {
|
||||||
|
await apiClient.post(`/deployments/${id}/restart`, message ? { message } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDeployment(id: string): Promise<DeploymentDetailResponse> {
|
export async function fetchDeployment(id: string): Promise<DeploymentDetailResponse> {
|
||||||
@@ -113,3 +109,23 @@ export async function fetchDeploymentMetrics(): Promise<DeploymentMetrics> {
|
|||||||
const { data } = await apiClient.get("/deployments/metrics/summary");
|
const { data } = await apiClient.get("/deployments/metrics/summary");
|
||||||
return data as DeploymentMetrics;
|
return data as DeploymentMetrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchDeploymentComposeFiles(
|
||||||
|
repoUrl: string,
|
||||||
|
branch: string
|
||||||
|
): Promise<ComposeFile[]> {
|
||||||
|
const { data } = await apiClient.get("/deployments/compose-files", {
|
||||||
|
params: { repoUrl, branch }
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { apiClient } from "./client";
|
|||||||
export interface SettingsResponse {
|
export interface SettingsResponse {
|
||||||
webhookToken: string;
|
webhookToken: string;
|
||||||
webhookSecret: string;
|
webhookSecret: string;
|
||||||
|
cleanupIntervalValue?: number;
|
||||||
|
cleanupIntervalUnit?: "saat" | "gün" | "hafta";
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,3 +22,13 @@ export async function rotateWebhookSecret(): Promise<SettingsResponse> {
|
|||||||
const { data } = await apiClient.post("/settings/secret/rotate");
|
const { data } = await apiClient.post("/settings/secret/rotate");
|
||||||
return data as SettingsResponse;
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
24
frontend/src/components/ui/scroll-area.tsx
Normal file
24
frontend/src/components/ui/scroll-area.tsx
Normal 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 };
|
||||||
49
frontend/src/components/ui/tabs.tsx
Normal file
49
frontend/src/components/ui/tabs.tsx
Normal 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 };
|
||||||
@@ -1,12 +1,48 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faArrowLeft, faCloudArrowUp, faCopy, faHistory } from "@fortawesome/free-solid-svg-icons";
|
import {
|
||||||
|
faArrowLeft,
|
||||||
|
faCloudArrowUp,
|
||||||
|
faCopy,
|
||||||
|
faEye,
|
||||||
|
faEyeSlash,
|
||||||
|
faHistory,
|
||||||
|
faRotate
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||||
import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments";
|
import { Label } from "../components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
|
import {
|
||||||
|
DeploymentInput,
|
||||||
|
DeploymentProject,
|
||||||
|
DeploymentRun,
|
||||||
|
fetchDeployment,
|
||||||
|
fetchDeploymentBranches,
|
||||||
|
fetchDeploymentComposeFiles,
|
||||||
|
fetchDeploymentEnvExamples,
|
||||||
|
restartDeployment,
|
||||||
|
runDeployment,
|
||||||
|
updateDeployment
|
||||||
|
} from "../api/deployments";
|
||||||
|
import { useDeploymentStream } from "../providers/live-provider";
|
||||||
|
import { useSocket } from "../providers/socket-provider";
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
_id?: string;
|
||||||
|
name: string;
|
||||||
|
repoUrl: string;
|
||||||
|
branch: string;
|
||||||
|
composeFile: DeploymentInput["composeFile"];
|
||||||
|
port: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EnvExample = { name: string; content: string };
|
||||||
|
|
||||||
export function DeploymentDetailPage() {
|
export function DeploymentDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -15,6 +51,29 @@ export function DeploymentDetailPage() {
|
|||||||
const [runs, setRuns] = useState<DeploymentRun[]>([]);
|
const [runs, setRuns] = useState<DeploymentRun[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [triggering, setTriggering] = useState(false);
|
const [triggering, setTriggering] = useState(false);
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState<FormState>({
|
||||||
|
name: "",
|
||||||
|
repoUrl: "",
|
||||||
|
branch: "main",
|
||||||
|
composeFile: "docker-compose.yml",
|
||||||
|
port: ""
|
||||||
|
});
|
||||||
|
const [branchOptions, setBranchOptions] = useState<string[]>([]);
|
||||||
|
const [branchLoading, setBranchLoading] = useState(false);
|
||||||
|
const [composeOptions, setComposeOptions] = useState<DeploymentInput["composeFile"][]>([]);
|
||||||
|
const [composeLoading, setComposeLoading] = useState(false);
|
||||||
|
const [envExamples, setEnvExamples] = useState<EnvExample[]>([]);
|
||||||
|
const [envLoading, setEnvLoading] = useState(false);
|
||||||
|
const [envContent, setEnvContent] = useState("");
|
||||||
|
const [envExampleName, setEnvExampleName] = useState("");
|
||||||
|
const [showEnv, setShowEnv] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState("details");
|
||||||
|
const stream = useDeploymentStream(id || "");
|
||||||
|
const socket = useSocket();
|
||||||
|
const isEdit = useMemo(() => !!form._id, [form._id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -27,12 +86,36 @@ export function DeploymentDetailPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket || !id) return;
|
||||||
|
socket.emit("deployment:subscribe", { deploymentId: id });
|
||||||
|
const handleRunUpdate = ({ deploymentId, run }: { deploymentId: string; run: DeploymentRun }) => {
|
||||||
|
if (deploymentId !== id) return;
|
||||||
|
setRuns((prev) => {
|
||||||
|
const existingIndex = prev.findIndex((item) => item._id === run._id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const next = [...prev];
|
||||||
|
next[existingIndex] = { ...next[existingIndex], ...run };
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [run, ...prev];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
socket.on("deployment:run", handleRunUpdate);
|
||||||
|
return () => {
|
||||||
|
socket.emit("deployment:unsubscribe", { deploymentId: id });
|
||||||
|
socket.off("deployment:run", handleRunUpdate);
|
||||||
|
};
|
||||||
|
}, [socket, id]);
|
||||||
|
|
||||||
const webhookUrl = useMemo(() => {
|
const webhookUrl = useMemo(() => {
|
||||||
if (!project) return "";
|
if (!project) return "";
|
||||||
return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`;
|
return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`;
|
||||||
}, [project]);
|
}, [project]);
|
||||||
|
|
||||||
const latestRun = runs[0];
|
const latestRun = runs[0];
|
||||||
|
const effectiveStatus = stream.status || project?.lastStatus || latestRun?.status || "idle";
|
||||||
|
const currentLogs = stream.logs.length > 0 ? stream.logs : latestRun?.logs || [];
|
||||||
|
|
||||||
const decorateLogLine = (line: string) => {
|
const decorateLogLine = (line: string) => {
|
||||||
const lower = line.toLowerCase();
|
const lower = line.toLowerCase();
|
||||||
@@ -56,7 +139,20 @@ export function DeploymentDetailPage() {
|
|||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(webhookUrl);
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(webhookUrl);
|
||||||
|
} else {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = webhookUrl;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
const ok = document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
if (!ok) throw new Error("copy failed");
|
||||||
|
}
|
||||||
toast.success("Webhook URL kopyalandı");
|
toast.success("Webhook URL kopyalandı");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Webhook URL kopyalanamadı");
|
toast.error("Webhook URL kopyalanamadı");
|
||||||
@@ -76,6 +172,155 @@ export function DeploymentDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRestart = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
await restartDeployment(id, "restart");
|
||||||
|
toast.success("Restart tetiklendi");
|
||||||
|
} catch {
|
||||||
|
toast.error("Restart tetiklenemedi");
|
||||||
|
} finally {
|
||||||
|
setRestarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const repoUrl = form.repoUrl.trim();
|
||||||
|
if (!repoUrl) {
|
||||||
|
setBranchOptions([]);
|
||||||
|
setComposeOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setBranchLoading(true);
|
||||||
|
try {
|
||||||
|
const branches = await fetchDeploymentBranches(repoUrl);
|
||||||
|
setBranchOptions(branches);
|
||||||
|
if (!form.branch && branches.length > 0) {
|
||||||
|
setForm((prev) => ({ ...prev, branch: branches.includes("main") ? "main" : branches[0] }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setBranchOptions([]);
|
||||||
|
} finally {
|
||||||
|
setBranchLoading(false);
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [form.repoUrl, form.branch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const repoUrl = form.repoUrl.trim();
|
||||||
|
const branch = form.branch.trim();
|
||||||
|
if (!repoUrl || !branch) {
|
||||||
|
setEnvExamples([]);
|
||||||
|
setEnvExampleName("");
|
||||||
|
setComposeOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setComposeLoading(true);
|
||||||
|
try {
|
||||||
|
const files = await fetchDeploymentComposeFiles(repoUrl, branch);
|
||||||
|
setComposeOptions(files);
|
||||||
|
if (files.length > 0 && !files.includes(form.composeFile)) {
|
||||||
|
setForm((prev) => ({ ...prev, composeFile: files[0] }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setComposeOptions([]);
|
||||||
|
} finally {
|
||||||
|
setComposeLoading(false);
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [form.repoUrl, form.branch, form.composeFile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const repoUrl = form.repoUrl.trim();
|
||||||
|
const branch = form.branch.trim();
|
||||||
|
if (!repoUrl || !branch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setEnvLoading(true);
|
||||||
|
try {
|
||||||
|
const examples = await fetchDeploymentEnvExamples(repoUrl, branch);
|
||||||
|
setEnvExamples(examples);
|
||||||
|
if (examples.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selected = examples.find((example) => example.name === envExampleName) || examples[0];
|
||||||
|
if (!isEdit || !envContent) {
|
||||||
|
setEnvExampleName(selected.name);
|
||||||
|
setEnvContent(selected.content);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setEnvExamples([]);
|
||||||
|
} finally {
|
||||||
|
setEnvLoading(false);
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [form.repoUrl, form.branch, envExampleName, isEdit, envContent]);
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (!project) return;
|
||||||
|
const { _id, name, repoUrl, branch, composeFile, port } = project;
|
||||||
|
setForm({
|
||||||
|
_id,
|
||||||
|
name,
|
||||||
|
repoUrl,
|
||||||
|
branch,
|
||||||
|
composeFile,
|
||||||
|
port: port ? String(port) : ""
|
||||||
|
});
|
||||||
|
setEnvContent(project.envContent || "");
|
||||||
|
setEnvExampleName(project.envExampleName || "");
|
||||||
|
setShowEnv(false);
|
||||||
|
setActiveTab("details");
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form._id) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: DeploymentInput = {
|
||||||
|
name: form.name,
|
||||||
|
repoUrl: form.repoUrl,
|
||||||
|
branch: form.branch,
|
||||||
|
composeFile: form.composeFile,
|
||||||
|
port: form.port ? Number(form.port) : undefined,
|
||||||
|
envContent: envContent.trim() ? envContent : undefined,
|
||||||
|
envExampleName: envExampleName || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) {
|
||||||
|
toast.error("Tüm alanları doldurun");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateDeployment(form._id, payload);
|
||||||
|
setProject(updated);
|
||||||
|
try {
|
||||||
|
await runDeployment(updated._id, "update deploy");
|
||||||
|
} catch {
|
||||||
|
toast.error("Deploy tetiklenemedi");
|
||||||
|
}
|
||||||
|
toast.success("Deployment güncellendi");
|
||||||
|
setModalOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast.error("İşlem sırasında hata oluştu");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
||||||
@@ -93,7 +338,8 @@ export function DeploymentDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/deployments")}>
|
<Button variant="ghost" size="icon" onClick={() => navigate("/deployments")}>
|
||||||
@@ -107,10 +353,14 @@ export function DeploymentDetailPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate("/deployments", { state: { editDeploymentId: project._id } })}
|
onClick={handleEdit}
|
||||||
>
|
>
|
||||||
Düzenle
|
Düzenle
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={handleRestart} disabled={restarting} className="gap-2">
|
||||||
|
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
|
||||||
|
{restarting ? "Restarting..." : "Restart"}
|
||||||
|
</Button>
|
||||||
<Button onClick={handleRun} disabled={triggering} className="gap-2">
|
<Button onClick={handleRun} disabled={triggering} className="gap-2">
|
||||||
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
|
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
|
||||||
{triggering ? "Deploying..." : "Deploy"}
|
{triggering ? "Deploying..." : "Deploy"}
|
||||||
@@ -121,7 +371,7 @@ export function DeploymentDetailPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Genel Bilgiler</CardTitle>
|
<CardTitle>Genel Bilgiler</CardTitle>
|
||||||
<JobStatusBadge status={project.lastStatus} />
|
<JobStatusBadge status={effectiveStatus} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 text-sm text-muted-foreground">
|
<CardContent className="grid gap-4 text-sm text-muted-foreground">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
@@ -172,29 +422,32 @@ export function DeploymentDetailPage() {
|
|||||||
Deploy Geçmişi
|
Deploy Geçmişi
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="max-h-[520px] overflow-y-auto pr-2">
|
||||||
{runs.length === 0 && (
|
{runs.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">Henüz deploy çalıştırılmadı.</div>
|
<div className="text-sm text-muted-foreground">Henüz deploy çalıştırılmadı.</div>
|
||||||
)}
|
) : (
|
||||||
{runs.map((run) => (
|
<div className="space-y-3">
|
||||||
<div
|
{runs.map((run) => (
|
||||||
key={run._id}
|
<div
|
||||||
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm"
|
key={run._id}
|
||||||
>
|
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||||
<div className="flex items-center gap-3">
|
>
|
||||||
<JobStatusBadge status={run.status} />
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-muted-foreground">
|
<JobStatusBadge status={run.status} />
|
||||||
{new Date(run.startedAt).toLocaleString()}
|
<span className="text-muted-foreground">
|
||||||
</span>
|
{new Date(run.startedAt).toLocaleString()}
|
||||||
{run.message && (
|
</span>
|
||||||
<span className="truncate text-foreground/80">· {run.message}</span>
|
{run.message && (
|
||||||
)}
|
<span className="truncate text-foreground/80">· {run.message}</span>
|
||||||
</div>
|
)}
|
||||||
<div className="text-muted-foreground">
|
</div>
|
||||||
{run.durationMs ? `${Math.round(run.durationMs / 1000)}s` : "-"}
|
<div className="text-muted-foreground">
|
||||||
</div>
|
{run.durationMs ? `${Math.round(run.durationMs / 1000)}s` : "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -204,8 +457,8 @@ export function DeploymentDetailPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="max-h-72 overflow-auto rounded-md border border-border bg-black px-3 py-2 font-mono text-xs text-green-100">
|
<div className="max-h-72 overflow-auto rounded-md border border-border bg-black px-3 py-2 font-mono text-xs text-green-100">
|
||||||
{latestRun?.logs?.length ? (
|
{currentLogs.length ? (
|
||||||
latestRun.logs.map((line, idx) => (
|
[...currentLogs].reverse().map((line, idx) => (
|
||||||
<div key={idx} className="whitespace-pre-wrap">
|
<div key={idx} className="whitespace-pre-wrap">
|
||||||
{decorateLogLine(line)}
|
{decorateLogLine(line)}
|
||||||
</div>
|
</div>
|
||||||
@@ -216,6 +469,231 @@ export function DeploymentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{modalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
|
||||||
|
<div
|
||||||
|
className="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"
|
||||||
|
style={{ height: 620 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-lg font-semibold text-foreground">Deployment Güncelle</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Repo ve branch seçimi sonrası webhook tetiklemeleriyle deploy yapılır.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={handleClose}>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden px-5 py-4">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="details">Genel</TabsTrigger>
|
||||||
|
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="details" className="h-[420px] space-y-4">
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||||
|
Repo URL girildiğinde branch ve compose dosyaları listelenir.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="repo">Repo URL</Label>
|
||||||
|
<Input
|
||||||
|
id="repo"
|
||||||
|
value={form.repoUrl}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
|
||||||
|
placeholder="https://gitea.example.com/org/repo"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Deployment Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="wisecolt-app"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="branch">Branch</Label>
|
||||||
|
{branchOptions.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={form.branch}
|
||||||
|
onValueChange={(value) => setForm((prev) => ({ ...prev, branch: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Branch seçin" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{branchOptions.map((branch) => (
|
||||||
|
<SelectItem key={branch} value={branch}>
|
||||||
|
{branch}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id="branch"
|
||||||
|
value={form.branch}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, branch: e.target.value }))}
|
||||||
|
placeholder="main"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||||
|
{branchLoading
|
||||||
|
? "Branch listesi alınıyor..."
|
||||||
|
: branchOptions.length > 0
|
||||||
|
? "Repo üzerindeki branch'lar listelendi."
|
||||||
|
: "Repo URL girildiğinde branch listesi otomatik gelir."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Compose Dosyası</Label>
|
||||||
|
<Select
|
||||||
|
value={form.composeFile}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
composeFile: value as DeploymentInput["composeFile"]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Compose seçin" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(composeOptions.length > 0
|
||||||
|
? composeOptions
|
||||||
|
: ["docker-compose.yml", "docker-compose.dev.yml"]
|
||||||
|
).map((file) => (
|
||||||
|
<SelectItem key={file} value={file}>
|
||||||
|
{file}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||||
|
{composeLoading
|
||||||
|
? "Compose dosyaları alınıyor..."
|
||||||
|
: composeOptions.length > 0
|
||||||
|
? "Repo üzerindeki compose dosyaları listelendi."
|
||||||
|
: "Repo URL ve branch sonrası compose dosyaları listelenir."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="port">Port (opsiyonel)</Label>
|
||||||
|
<Input
|
||||||
|
id="port"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, port: e.target.value }))}
|
||||||
|
placeholder="3000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="environment" className="h-[420px] space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>.env.example</Label>
|
||||||
|
{envExamples.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={envExampleName}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const example = envExamples.find((item) => item.name === value);
|
||||||
|
setEnvExampleName(value);
|
||||||
|
if (example) {
|
||||||
|
setEnvContent(example.content);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Env example seçin" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{envExamples.map((example) => (
|
||||||
|
<SelectItem key={example.name} value={example.name}>
|
||||||
|
{example.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="h-[2.5rem] rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{envLoading
|
||||||
|
? "Env example dosyaları alınıyor..."
|
||||||
|
: "Repo içinde .env.example bulunamadı."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||||
|
{envExamples.length > 0
|
||||||
|
? "Repo üzerindeki env example dosyaları listelendi."
|
||||||
|
: envLoading
|
||||||
|
? "Env example dosyaları alınıyor..."
|
||||||
|
: "Repo içinde .env.example bulunamadı."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="env-content">Environment</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowEnv((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={showEnv ? faEyeSlash : faEye} className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="env-content"
|
||||||
|
value={envContent}
|
||||||
|
onChange={(e) => setEnvContent(e.target.value)}
|
||||||
|
className="h-[180px] w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm font-mono text-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
style={
|
||||||
|
showEnv ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties)
|
||||||
|
}
|
||||||
|
placeholder="ENV içerikleri burada listelenir."
|
||||||
|
/>
|
||||||
|
<div className="min-h-[1.25rem] text-xs text-muted-foreground">
|
||||||
|
Kaydedince içerik deployment kök dizinine{" "}
|
||||||
|
<span className="font-mono">.env</span> olarak yazılır.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
|
||||||
|
<Button variant="ghost" onClick={handleClose} disabled={saving}>
|
||||||
|
İptal
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "Kaydediliyor..." : "Kaydet"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import {
|
||||||
faCloudArrowUp,
|
faCloudArrowUp,
|
||||||
|
faEye,
|
||||||
|
faEyeSlash,
|
||||||
|
faPenToSquare,
|
||||||
faPlus,
|
faPlus,
|
||||||
faRotate,
|
faRotate,
|
||||||
faRocket
|
faRocket
|
||||||
@@ -13,33 +16,36 @@ import { Button } from "../components/ui/button";
|
|||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Label } from "../components/ui/label";
|
import { Label } from "../components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
createDeployment,
|
createDeployment,
|
||||||
deleteDeployment,
|
deleteDeployment,
|
||||||
DeploymentCandidate,
|
|
||||||
DeploymentInput,
|
DeploymentInput,
|
||||||
DeploymentProject,
|
DeploymentProject,
|
||||||
|
fetchDeploymentComposeFiles,
|
||||||
fetchDeploymentBranches,
|
fetchDeploymentBranches,
|
||||||
|
fetchDeploymentEnvExamples,
|
||||||
fetchDeployments,
|
fetchDeployments,
|
||||||
|
restartDeployment,
|
||||||
runDeployment,
|
runDeployment,
|
||||||
scanDeployments,
|
|
||||||
updateDeployment
|
updateDeployment
|
||||||
} from "../api/deployments";
|
} from "../api/deployments";
|
||||||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||||
|
import { useLiveData } from "../providers/live-provider";
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
_id?: string;
|
_id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
rootPath: string;
|
|
||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
composeFile: DeploymentInput["composeFile"];
|
composeFile: DeploymentInput["composeFile"];
|
||||||
port: string;
|
port: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EnvExample = { name: string; content: string };
|
||||||
|
|
||||||
const defaultForm: FormState = {
|
const defaultForm: FormState = {
|
||||||
name: "",
|
name: "",
|
||||||
rootPath: "",
|
|
||||||
repoUrl: "",
|
repoUrl: "",
|
||||||
branch: "main",
|
branch: "main",
|
||||||
composeFile: "docker-compose.yml",
|
composeFile: "docker-compose.yml",
|
||||||
@@ -49,24 +55,27 @@ const defaultForm: FormState = {
|
|||||||
export function DeploymentsPage() {
|
export function DeploymentsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { deploymentStreams } = useLiveData();
|
||||||
const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "");
|
const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "");
|
||||||
const [deployments, setDeployments] = useState<DeploymentProject[]>([]);
|
const [deployments, setDeployments] = useState<DeploymentProject[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [scanning, setScanning] = useState(false);
|
|
||||||
const [candidates, setCandidates] = useState<DeploymentCandidate[]>([]);
|
|
||||||
const [form, setForm] = useState<FormState>(defaultForm);
|
const [form, setForm] = useState<FormState>(defaultForm);
|
||||||
const [pendingEditId, setPendingEditId] = useState<string | null>(null);
|
const [pendingEditId, setPendingEditId] = useState<string | null>(null);
|
||||||
const [branchOptions, setBranchOptions] = useState<string[]>([]);
|
const [branchOptions, setBranchOptions] = useState<string[]>([]);
|
||||||
const [branchLoading, setBranchLoading] = useState(false);
|
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 [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const isEdit = useMemo(() => !!form._id, [form._id]);
|
const isEdit = useMemo(() => !!form._id, [form._id]);
|
||||||
const selectedCandidate = useMemo(
|
|
||||||
() => candidates.find((c) => c.rootPath === form.rootPath),
|
|
||||||
[candidates, form.rootPath]
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadDeployments = async () => {
|
const loadDeployments = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -80,18 +89,6 @@ export function DeploymentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadCandidates = async () => {
|
|
||||||
setScanning(true);
|
|
||||||
try {
|
|
||||||
const data = await scanDeployments();
|
|
||||||
setCandidates(data);
|
|
||||||
} catch {
|
|
||||||
toast.error("Root taraması yapılamadı");
|
|
||||||
} finally {
|
|
||||||
setScanning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDeployments();
|
loadDeployments();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -100,8 +97,17 @@ export function DeploymentsPage() {
|
|||||||
const repoUrl = form.repoUrl.trim();
|
const repoUrl = form.repoUrl.trim();
|
||||||
if (!repoUrl) {
|
if (!repoUrl) {
|
||||||
setBranchOptions([]);
|
setBranchOptions([]);
|
||||||
|
setComposeOptions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!form._id && !form.name) {
|
||||||
|
const normalized = repoUrl.replace(/\/+$/, "");
|
||||||
|
const lastPart = normalized.split("/").pop() || "";
|
||||||
|
const name = lastPart.replace(/\.git$/i, "");
|
||||||
|
if (name) {
|
||||||
|
setForm((prev) => ({ ...prev, name }));
|
||||||
|
}
|
||||||
|
}
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
setBranchLoading(true);
|
setBranchLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -119,6 +125,67 @@ export function DeploymentsPage() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [form.repoUrl, form.branch]);
|
}, [form.repoUrl, form.branch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const repoUrl = form.repoUrl.trim();
|
||||||
|
const branch = form.branch.trim();
|
||||||
|
if (!repoUrl || !branch) {
|
||||||
|
setEnvExamples([]);
|
||||||
|
setEnvExampleName("");
|
||||||
|
if (!isEdit) {
|
||||||
|
setEnvContent("");
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
if (!isEdit) {
|
||||||
|
setEnvExampleName("");
|
||||||
|
setEnvContent("");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selected = examples.find((example) => example.name === envExampleName) || examples[0];
|
||||||
|
if (!isEdit || !envContent) {
|
||||||
|
setEnvExampleName(selected.name);
|
||||||
|
setEnvContent(selected.content);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setEnvExamples([]);
|
||||||
|
} finally {
|
||||||
|
setEnvLoading(false);
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [form.repoUrl, form.branch, envExampleName, isEdit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state = location.state as { editDeploymentId?: string } | null;
|
const state = location.state as { editDeploymentId?: string } | null;
|
||||||
if (state?.editDeploymentId) {
|
if (state?.editDeploymentId) {
|
||||||
@@ -139,21 +206,29 @@ export function DeploymentsPage() {
|
|||||||
const handleOpenNew = async () => {
|
const handleOpenNew = async () => {
|
||||||
setForm(defaultForm);
|
setForm(defaultForm);
|
||||||
setBranchOptions([]);
|
setBranchOptions([]);
|
||||||
|
setComposeOptions([]);
|
||||||
|
setEnvExamples([]);
|
||||||
|
setEnvContent("");
|
||||||
|
setEnvExampleName("");
|
||||||
|
setShowEnv(false);
|
||||||
|
setActiveTab("details");
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
await loadCandidates();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (deployment: DeploymentProject) => {
|
const handleEdit = (deployment: DeploymentProject) => {
|
||||||
const { _id, name, rootPath, repoUrl, branch, composeFile, port } = deployment;
|
const { _id, name, repoUrl, branch, composeFile, port } = deployment;
|
||||||
setForm({
|
setForm({
|
||||||
_id,
|
_id,
|
||||||
name,
|
name,
|
||||||
rootPath,
|
|
||||||
repoUrl,
|
repoUrl,
|
||||||
branch,
|
branch,
|
||||||
composeFile,
|
composeFile,
|
||||||
port: port ? String(port) : ""
|
port: port ? String(port) : ""
|
||||||
});
|
});
|
||||||
|
setEnvContent(deployment.envContent || "");
|
||||||
|
setEnvExampleName(deployment.envExampleName || "");
|
||||||
|
setShowEnv(false);
|
||||||
|
setActiveTab("details");
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,14 +241,15 @@ export function DeploymentsPage() {
|
|||||||
try {
|
try {
|
||||||
const payload: DeploymentInput = {
|
const payload: DeploymentInput = {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
rootPath: form.rootPath,
|
|
||||||
repoUrl: form.repoUrl,
|
repoUrl: form.repoUrl,
|
||||||
branch: form.branch,
|
branch: form.branch,
|
||||||
composeFile: form.composeFile,
|
composeFile: form.composeFile,
|
||||||
port: form.port ? Number(form.port) : undefined
|
port: form.port ? Number(form.port) : undefined,
|
||||||
|
envContent: envContent.trim() ? envContent : undefined,
|
||||||
|
envExampleName: envExampleName || undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!payload.name || !payload.rootPath || !payload.repoUrl || !payload.branch || !payload.composeFile) {
|
if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) {
|
||||||
toast.error("Tüm alanları doldurun");
|
toast.error("Tüm alanları doldurun");
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
return;
|
return;
|
||||||
@@ -185,9 +261,16 @@ export function DeploymentsPage() {
|
|||||||
repoUrl: payload.repoUrl,
|
repoUrl: payload.repoUrl,
|
||||||
branch: payload.branch,
|
branch: payload.branch,
|
||||||
composeFile: payload.composeFile,
|
composeFile: payload.composeFile,
|
||||||
port: payload.port
|
port: payload.port,
|
||||||
|
envContent: payload.envContent,
|
||||||
|
envExampleName: payload.envExampleName
|
||||||
});
|
});
|
||||||
setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d)));
|
setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d)));
|
||||||
|
try {
|
||||||
|
await runDeployment(updated._id, "update deploy");
|
||||||
|
} catch {
|
||||||
|
toast.error("Deploy tetiklenemedi");
|
||||||
|
}
|
||||||
toast.success("Deployment güncellendi");
|
toast.success("Deployment güncellendi");
|
||||||
} else {
|
} else {
|
||||||
const created = await createDeployment(payload);
|
const created = await createDeployment(payload);
|
||||||
@@ -212,6 +295,15 @@ export function DeploymentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRestart = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await restartDeployment(id, "restart");
|
||||||
|
toast.success("Restart tetiklendi");
|
||||||
|
} catch {
|
||||||
|
toast.error("Restart tetiklenemedi");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (deployment: DeploymentProject) => {
|
const handleDelete = async (deployment: DeploymentProject) => {
|
||||||
const ok = window.confirm("Bu deployment'ı silmek istediğinize emin misiniz?");
|
const ok = window.confirm("Bu deployment'ı silmek istediğinize emin misiniz?");
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -288,7 +380,9 @@ export function DeploymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||||
<JobStatusBadge status={deployment.lastStatus} />
|
<JobStatusBadge
|
||||||
|
status={deploymentStreams[deployment._id]?.status || deployment.lastStatus}
|
||||||
|
/>
|
||||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
|
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
|
||||||
{deployment.env.toUpperCase()}
|
{deployment.env.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
@@ -310,6 +404,18 @@ export function DeploymentsPage() {
|
|||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
|
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRestart(deployment._id);
|
||||||
|
}}
|
||||||
|
title="Restart"
|
||||||
|
aria-label="Restart"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -319,7 +425,7 @@ export function DeploymentsPage() {
|
|||||||
}}
|
}}
|
||||||
title="Düzenle"
|
title="Düzenle"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
|
<FontAwesomeIcon icon={faPenToSquare} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -357,7 +463,10 @@ export function DeploymentsPage() {
|
|||||||
|
|
||||||
{modalOpen && (
|
{modalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
|
||||||
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-border bg-card card-shadow">
|
<div
|
||||||
|
className="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"
|
||||||
|
style={{ height: 626 }}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-lg font-semibold text-foreground">
|
<div className="text-lg font-semibold text-foreground">
|
||||||
@@ -372,148 +481,197 @@ export function DeploymentsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4">
|
<div className="flex-1 overflow-hidden px-5 py-4">
|
||||||
{!isEdit && (
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<TabsList>
|
||||||
<div className="flex items-center justify-between">
|
<TabsTrigger value="details">Genel</TabsTrigger>
|
||||||
<Label>Proje Klasörü</Label>
|
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||||
<Button
|
</TabsList>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={loadCandidates}
|
|
||||||
disabled={scanning}
|
|
||||||
>
|
|
||||||
{scanning ? "Taranıyor..." : "Yeniden Tara"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={form.rootPath}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const candidate = candidates.find((c) => c.rootPath === value);
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
rootPath: value,
|
|
||||||
name: candidate?.name || prev.name,
|
|
||||||
composeFile: candidate?.composeFiles[0] || prev.composeFile
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Root altında proje seçin" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{candidates.map((candidate) => (
|
|
||||||
<SelectItem key={candidate.rootPath} value={candidate.rootPath}>
|
|
||||||
{candidate.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{scanning
|
|
||||||
? "Root dizin taranıyor..."
|
|
||||||
: candidates.length === 0
|
|
||||||
? "Root altında compose dosyası bulunan proje yok."
|
|
||||||
: "Compose dosyası bulunan klasörleri listeler."}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<TabsContent value="details" className="h-[420px] space-y-4">
|
||||||
<Label htmlFor="repo">Repo URL</Label>
|
{!isEdit && (
|
||||||
<Input
|
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||||
id="repo"
|
Repo URL girildiğinde branch ve compose dosyaları listelenir.
|
||||||
value={form.repoUrl}
|
</div>
|
||||||
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">
|
||||||
<div className="space-y-2">
|
<Label htmlFor="repo">Repo URL</Label>
|
||||||
<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
|
<Input
|
||||||
id="branch"
|
id="repo"
|
||||||
value={form.branch}
|
value={form.repoUrl}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, branch: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
|
||||||
placeholder="main"
|
placeholder="https://gitea.example.com/org/repo"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{branchLoading
|
|
||||||
? "Branch listesi alınıyor..."
|
|
||||||
: branchOptions.length > 0
|
|
||||||
? "Repo üzerindeki branch'lar listelendi."
|
|
||||||
: "Repo URL girildiğinde branch listesi otomatik gelir."}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Compose Dosyası</Label>
|
<Label htmlFor="name">Deployment Name</Label>
|
||||||
<Select
|
<Input
|
||||||
value={form.composeFile}
|
id="name"
|
||||||
onValueChange={(value) =>
|
value={form.name}
|
||||||
setForm((prev) => ({ ...prev, composeFile: value as DeploymentInput["composeFile"] }))
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
}
|
placeholder="wisecolt-app"
|
||||||
>
|
required
|
||||||
<SelectTrigger>
|
/>
|
||||||
<SelectValue placeholder="Compose seçin" />
|
</div>
|
||||||
</SelectTrigger>
|
<div className="space-y-2">
|
||||||
<SelectContent>
|
<Label htmlFor="branch">Branch</Label>
|
||||||
{(selectedCandidate?.composeFiles || ["docker-compose.yml", "docker-compose.dev.yml"]).map(
|
{branchOptions.length > 0 ? (
|
||||||
(file) => (
|
<Select
|
||||||
<SelectItem key={file} value={file}>
|
value={form.branch}
|
||||||
{file}
|
onValueChange={(value) => setForm((prev) => ({ ...prev, branch: value }))}
|
||||||
</SelectItem>
|
>
|
||||||
)
|
<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
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||||
</Select>
|
{branchLoading
|
||||||
</div>
|
? "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="space-y-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Label htmlFor="port">Port (opsiyonel)</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label>Compose Dosyası</Label>
|
||||||
id="port"
|
<Select
|
||||||
type="number"
|
value={form.composeFile}
|
||||||
min={1}
|
onValueChange={(value) =>
|
||||||
value={form.port}
|
setForm((prev) => ({
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, port: e.target.value }))}
|
...prev,
|
||||||
placeholder="3000"
|
composeFile: value as DeploymentInput["composeFile"]
|
||||||
/>
|
}))
|
||||||
</div>
|
}
|
||||||
</div>
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
|
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Line,
|
Line,
|
||||||
LineChart,
|
LineChart,
|
||||||
@@ -18,6 +18,7 @@ import { JobStatusBadge } from "../components/JobStatusBadge";
|
|||||||
import { RepoIcon } from "../components/RepoIcon";
|
import { RepoIcon } from "../components/RepoIcon";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faClockRotateLeft, faListCheck, faFlaskVial, faRocket } from "@fortawesome/free-solid-svg-icons";
|
import { faClockRotateLeft, faListCheck, faFlaskVial, faRocket } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { useAuth } from "../providers/auth-provider";
|
||||||
|
|
||||||
function formatDuration(ms?: number) {
|
function formatDuration(ms?: number) {
|
||||||
if (!ms || Number.isNaN(ms)) return "-";
|
if (!ms || Number.isNaN(ms)) return "-";
|
||||||
@@ -41,9 +42,14 @@ export function HomePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { jobStreams } = useLiveData();
|
const { jobStreams } = useLiveData();
|
||||||
|
const { token } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
const loadMetrics = useCallback(() => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()])
|
Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()])
|
||||||
.then(([jobResult, deployResult]) => {
|
.then(([jobResult, deployResult]) => {
|
||||||
if (jobResult.status === "fulfilled") {
|
if (jobResult.status === "fulfilled") {
|
||||||
@@ -65,7 +71,25 @@ export function HomePage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMetrics();
|
||||||
|
}, [loadMetrics, location.key]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
loadMetrics();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("focus", handleFocus);
|
||||||
|
document.addEventListener("visibilitychange", handleFocus);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("focus", handleFocus);
|
||||||
|
document.removeEventListener("visibilitychange", handleFocus);
|
||||||
|
};
|
||||||
|
}, [loadMetrics]);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (!metrics) {
|
if (!metrics) {
|
||||||
@@ -150,7 +174,24 @@ export function HomePage() {
|
|||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
}, [mergedRuns, deployRuns]);
|
}, [mergedRuns, deployRuns]);
|
||||||
|
|
||||||
const lastRunDuration = useMemo(() => formatDuration(mergedRuns[0]?.durationMs), [mergedRuns]);
|
const combinedTotals = useMemo(() => {
|
||||||
|
const jobSuccess = metrics?.dailyStats.reduce((acc, d) => acc + (d.success || 0), 0) ?? 0;
|
||||||
|
const jobTotal = metrics?.dailyStats.reduce((acc, d) => acc + (d.total || 0), 0) ?? 0;
|
||||||
|
const deploySuccess =
|
||||||
|
deploymentMetrics?.dailyStats.reduce((acc, d) => acc + (d.success || 0), 0) ?? 0;
|
||||||
|
const deployTotal =
|
||||||
|
deploymentMetrics?.dailyStats.reduce((acc, d) => acc + (d.total || 0), 0) ?? 0;
|
||||||
|
const totalRuns = jobTotal + deployTotal;
|
||||||
|
const successRate = totalRuns
|
||||||
|
? Math.round(((jobSuccess + deploySuccess) / totalRuns) * 100)
|
||||||
|
: 0;
|
||||||
|
return { totalRuns, successRate };
|
||||||
|
}, [metrics, deploymentMetrics]);
|
||||||
|
|
||||||
|
const lastRunDuration = useMemo(() => {
|
||||||
|
const latest = activityItems[0];
|
||||||
|
return formatDuration(latest?.durationMs);
|
||||||
|
}, [activityItems]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
@@ -163,7 +204,7 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
<FontAwesomeIcon icon={faClockRotateLeft} className="h-3.5 w-3.5" />
|
<FontAwesomeIcon icon={faClockRotateLeft} className="h-3.5 w-3.5" />
|
||||||
{metrics?.totals.totalRuns ?? 0} toplam koşu
|
{combinedTotals.totalRuns} toplam koşu
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-48 min-w-0">
|
<CardContent className="h-48 min-w-0">
|
||||||
@@ -210,13 +251,13 @@ export function HomePage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>Başarı Oranı</span>
|
<span>Başarı Oranı</span>
|
||||||
<span className="text-lg font-semibold text-foreground">
|
<span className="text-lg font-semibold text-foreground">
|
||||||
{metrics?.totals.successRate ?? 0}%
|
{combinedTotals.successRate}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>Toplam Çalıştırma</span>
|
<span>Toplam Çalıştırma</span>
|
||||||
<span className="text-lg font-semibold text-foreground">
|
<span className="text-lg font-semibold text-foreground">
|
||||||
{metrics?.totals.totalRuns ?? 0}
|
{combinedTotals.totalRuns}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -256,6 +297,7 @@ export function HomePage() {
|
|||||||
<RepoIcon repoUrl={run.repoUrl} />
|
<RepoIcon repoUrl={run.repoUrl} />
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<span>{run.title}</span>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${
|
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${
|
||||||
run.type === "test"
|
run.type === "test"
|
||||||
@@ -269,7 +311,6 @@ export function HomePage() {
|
|||||||
/>
|
/>
|
||||||
{run.type === "test" ? "Test" : "Deploy"}
|
{run.type === "test" ? "Test" : "Deploy"}
|
||||||
</span>
|
</span>
|
||||||
<span>{run.title}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{new Date(run.startedAt).toLocaleString()} · Süre:{" "}
|
{new Date(run.startedAt).toLocaleString()} · Süre:{" "}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { toast } from "sonner";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
import { 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() {
|
export function SettingsPage() {
|
||||||
const [settings, setSettings] = useState<SettingsResponse | null>(null);
|
const [settings, setSettings] = useState<SettingsResponse | null>(null);
|
||||||
@@ -13,17 +23,42 @@ export function SettingsPage() {
|
|||||||
const [rotatingSecret, setRotatingSecret] = useState(false);
|
const [rotatingSecret, setRotatingSecret] = useState(false);
|
||||||
const [showToken, setShowToken] = useState(false);
|
const [showToken, setShowToken] = useState(false);
|
||||||
const [showSecret, setShowSecret] = 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(() => {
|
useEffect(() => {
|
||||||
fetchSettings()
|
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"))
|
.catch(() => toast.error("Settings yüklenemedi"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCopy = async (value: string, label: string) => {
|
const handleCopy = async (value: string, label: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(value);
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
} else {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = value;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
const ok = document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
if (!ok) throw new Error("copy failed");
|
||||||
|
}
|
||||||
toast.success(`${label} kopyalandı`);
|
toast.success(`${label} kopyalandı`);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`${label} kopyalanamadı`);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
||||||
@@ -160,6 +225,58 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
setToken(stored);
|
setToken(stored);
|
||||||
fetchMe()
|
fetchMe()
|
||||||
.then((data) => setUser({ username: data.username }))
|
.then((data) => setUser({ username: data.username }))
|
||||||
.catch(() => setAuthToken(undefined))
|
.catch(() => {
|
||||||
|
setAuthToken(undefined);
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
} else {
|
} else {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type JobStream = {
|
|||||||
|
|
||||||
type LiveContextValue = {
|
type LiveContextValue = {
|
||||||
jobStreams: Record<string, JobStream>;
|
jobStreams: Record<string, JobStream>;
|
||||||
|
deploymentStreams: Record<string, JobStream>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LiveContext = createContext<LiveContextValue | undefined>(undefined);
|
const LiveContext = createContext<LiveContextValue | undefined>(undefined);
|
||||||
@@ -19,6 +20,7 @@ const LiveContext = createContext<LiveContextValue | undefined>(undefined);
|
|||||||
export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({});
|
const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({});
|
||||||
|
const [deploymentStreams, setDeploymentStreams] = useState<Record<string, JobStream>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
@@ -54,20 +56,59 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeploymentLog = ({ deploymentId, line }: { deploymentId: string; line: string }) => {
|
||||||
|
if (!deploymentId) return;
|
||||||
|
setDeploymentStreams((prev) => {
|
||||||
|
const current = prev[deploymentId] || { logs: [] };
|
||||||
|
const nextLogs = [...current.logs, line].slice(-200);
|
||||||
|
return { ...prev, [deploymentId]: { ...current, logs: nextLogs } };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeploymentStatus = ({
|
||||||
|
deploymentId,
|
||||||
|
status,
|
||||||
|
lastRunAt,
|
||||||
|
lastMessage,
|
||||||
|
runCount,
|
||||||
|
lastDurationMs
|
||||||
|
}: {
|
||||||
|
deploymentId: string;
|
||||||
|
status?: string;
|
||||||
|
lastRunAt?: string;
|
||||||
|
lastMessage?: string;
|
||||||
|
runCount?: number;
|
||||||
|
lastDurationMs?: number;
|
||||||
|
}) => {
|
||||||
|
if (!deploymentId) return;
|
||||||
|
setDeploymentStreams((prev) => {
|
||||||
|
const current = prev[deploymentId] || { logs: [] };
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[deploymentId]: { ...current, status, lastRunAt, lastMessage, runCount, lastDurationMs }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
socket.on("job:log", handleJobLog);
|
socket.on("job:log", handleJobLog);
|
||||||
socket.on("job:status", handleJobStatus);
|
socket.on("job:status", handleJobStatus);
|
||||||
|
socket.on("deployment:log", handleDeploymentLog);
|
||||||
|
socket.on("deployment:status", handleDeploymentStatus);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("job:log", handleJobLog);
|
socket.off("job:log", handleJobLog);
|
||||||
socket.off("job:status", handleJobStatus);
|
socket.off("job:status", handleJobStatus);
|
||||||
|
socket.off("deployment:log", handleDeploymentLog);
|
||||||
|
socket.off("deployment:status", handleDeploymentStatus);
|
||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
jobStreams
|
jobStreams,
|
||||||
|
deploymentStreams
|
||||||
}),
|
}),
|
||||||
[jobStreams]
|
[jobStreams, deploymentStreams]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
|
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
|
||||||
@@ -87,3 +128,12 @@ export function useJobStream(jobId: string) {
|
|||||||
[ctx.jobStreams, jobId]
|
[ctx.jobStreams, jobId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDeploymentStream(deploymentId: string) {
|
||||||
|
const ctx = useContext(LiveContext);
|
||||||
|
if (!ctx) throw new Error("useDeploymentStream LiveProvider içinde kullanılmalı");
|
||||||
|
return useMemo(
|
||||||
|
() => ctx.deploymentStreams[deploymentId] || { logs: [], status: "idle", runCount: 0 },
|
||||||
|
[ctx.deploymentStreams, deploymentId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
|||||||
const socketRef = useRef<Socket | null>(null);
|
const socketRef = useRef<Socket | null>(null);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
const baseUrl = useMemo(() => apiClient.defaults.baseURL || window.location.origin, []);
|
const baseUrl = useMemo(() => window.location.origin, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -22,6 +22,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
|||||||
|
|
||||||
const socket = io(baseUrl, {
|
const socket = io(baseUrl, {
|
||||||
auth: { token },
|
auth: { token },
|
||||||
|
path: "/api/socket.io",
|
||||||
transports: ["websocket", "polling"]
|
transports: ["websocket", "polling"]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user