Compare commits
14 Commits
deployment
...
b04ac03739
| Author | SHA1 | Date | |
|---|---|---|---|
| b04ac03739 | |||
| a117275efe | |||
| 003ddfcbd1 | |||
| 535b5cbdc2 | |||
| 2ff3fb6ee6 | |||
| 0092c28571 | |||
| fd020bd9d8 | |||
| e7a5690d98 | |||
| a87baa653a | |||
| a40d07917b | |||
| b6f6dcdff7 | |||
| 2393078933 | |||
| 2ad6431a28 | |||
| 2b053120cb |
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 Config ===
|
||||
API_KEY_LITE="your-lite-key"
|
||||
@@ -7,6 +18,3 @@ ACTIVE_KEY=lite
|
||||
# === Anthropic API Settings ===
|
||||
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
|
||||
ANTHROPIC_MODEL="glm-4.7"
|
||||
|
||||
# Host üzerinde projelerin bulunduğu dizin (compose volume için, zorunludur)
|
||||
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ dist
|
||||
.DS_Store
|
||||
test-runs
|
||||
backend/test-runs
|
||||
deployments/
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
PORT=4000
|
||||
# Prod için zorunlu Mongo bağlantısı
|
||||
# Örnek: mongodb://mongo:27017/wisecoltci
|
||||
MONGO_URI=mongodb://mongo:27017/wisecoltci
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=supersecret
|
||||
JWT_SECRET=change-me
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace
|
||||
@@ -1,4 +1,5 @@
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -8,7 +9,8 @@ export const config = {
|
||||
adminUsername: process.env.ADMIN_USERNAME || "admin",
|
||||
adminPassword: process.env.ADMIN_PASSWORD || "password",
|
||||
jwtSecret: process.env.JWT_SECRET || "changeme",
|
||||
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173"
|
||||
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173",
|
||||
deploymentsRoot: process.env.DEPLOYMENTS_ROOT || path.join(process.cwd(), "deployments")
|
||||
};
|
||||
|
||||
if (!config.jwtSecret) {
|
||||
|
||||
@@ -11,6 +11,8 @@ import webhookRoutes from "./routes/webhooks.js";
|
||||
import { config } from "./config/env.js";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { jobService } from "./services/jobService.js";
|
||||
import { deploymentService } from "./services/deploymentService.js";
|
||||
import { DeploymentProject } from "./models/deploymentProject.js";
|
||||
import { Job } from "./models/job.js";
|
||||
|
||||
const app = express();
|
||||
@@ -42,6 +44,7 @@ app.use("/api/settings", settingsRoutes);
|
||||
const server = http.createServer(app);
|
||||
|
||||
const io = new Server(server, {
|
||||
path: "/api/socket.io",
|
||||
cors: {
|
||||
origin: config.clientOrigin,
|
||||
methods: ["GET", "POST"]
|
||||
@@ -49,6 +52,7 @@ const io = new Server(server, {
|
||||
});
|
||||
|
||||
jobService.setSocket(io);
|
||||
deploymentService.setSocket(io);
|
||||
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth?.token as string | undefined;
|
||||
@@ -93,13 +97,39 @@ io.on("connection", (socket) => {
|
||||
if (!jobId) return;
|
||||
socket.leave(`job:${jobId}`);
|
||||
});
|
||||
|
||||
socket.on("deployment:subscribe", async ({ deploymentId }: { deploymentId: string }) => {
|
||||
if (!deploymentId) return;
|
||||
socket.join(`deployment:${deploymentId}`);
|
||||
try {
|
||||
const deployment = await DeploymentProject.findById(deploymentId);
|
||||
if (deployment) {
|
||||
socket.emit("deployment:status", {
|
||||
deploymentId,
|
||||
status: deployment.lastStatus,
|
||||
lastRunAt: deployment.lastDeployAt,
|
||||
lastMessage: deployment.lastMessage
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// sessizce geç
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("deployment:unsubscribe", ({ deploymentId }: { deploymentId: string }) => {
|
||||
if (!deploymentId) return;
|
||||
socket.leave(`deployment:${deploymentId}`);
|
||||
});
|
||||
});
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
await mongoose.connect(config.mongoUri);
|
||||
console.log("MongoDB'ye bağlanıldı");
|
||||
await jobService.bootstrapFromFilesystem();
|
||||
await jobService.bootstrap();
|
||||
await deploymentService.normalizeExistingCommitMessages();
|
||||
await deploymentService.bootstrapFromFilesystem();
|
||||
|
||||
server.listen(config.port, () => {
|
||||
console.log(`Sunucu ${config.port} portunda çalışıyor`);
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface DeploymentProjectDocument extends Document {
|
||||
webhookToken: string;
|
||||
env: DeploymentEnv;
|
||||
port?: number;
|
||||
envContent?: string;
|
||||
envExampleName?: string;
|
||||
lastDeployAt?: Date;
|
||||
lastStatus: DeploymentStatus;
|
||||
lastMessage?: string;
|
||||
@@ -34,6 +36,8 @@ const DeploymentProjectSchema = new Schema<DeploymentProjectDocument>(
|
||||
webhookToken: { type: String, required: true, unique: true, index: true },
|
||||
env: { type: String, required: true, enum: ["dev", "prod"] },
|
||||
port: { type: Number },
|
||||
envContent: { type: String },
|
||||
envExampleName: { type: String },
|
||||
lastDeployAt: { type: Date },
|
||||
lastStatus: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" },
|
||||
lastMessage: { type: String }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { authMiddleware } from "../middleware/authMiddleware.js";
|
||||
import { deploymentService } from "../services/deploymentService.js";
|
||||
import { DeploymentProject } from "../models/deploymentProject.js";
|
||||
import { DeploymentRun } from "../models/deploymentRun.js";
|
||||
import fs from "fs";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -70,8 +70,28 @@ router.get("/compose-files", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/env-examples", async (req, res) => {
|
||||
authMiddleware(req, res, async () => {
|
||||
const repoUrl = req.query.repoUrl as string | undefined;
|
||||
const branch = req.query.branch as string | undefined;
|
||||
if (!repoUrl || !branch) {
|
||||
return res.status(400).json({ message: "repoUrl ve branch gerekli" });
|
||||
}
|
||||
try {
|
||||
const examples = await deploymentService.listRemoteEnvExamples(repoUrl, branch);
|
||||
return res.json({ examples });
|
||||
} catch (err) {
|
||||
return res.status(400).json({ message: "Env example alınamadı", error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/metrics/summary", async (req, res) => {
|
||||
authMiddleware(req, res, async () => {
|
||||
const deploymentCount = await DeploymentProject.countDocuments();
|
||||
if (deploymentCount === 0) {
|
||||
await deploymentService.bootstrapFromFilesystem();
|
||||
}
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - 7);
|
||||
|
||||
@@ -108,7 +128,11 @@ router.get("/metrics/summary", async (req, res) => {
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
authMiddleware(_req, res, async () => {
|
||||
const projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean();
|
||||
let projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean();
|
||||
if (projects.length === 0) {
|
||||
await deploymentService.bootstrapFromFilesystem();
|
||||
projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean();
|
||||
}
|
||||
return res.json(projects);
|
||||
});
|
||||
});
|
||||
@@ -128,7 +152,7 @@ router.get("/:id", async (req, res) => {
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
authMiddleware(req, res, async () => {
|
||||
const { name, repoUrl, branch, composeFile, port } = req.body;
|
||||
const { name, repoUrl, branch, composeFile, port, envContent, envExampleName } = req.body;
|
||||
if (!name || !repoUrl || !branch || !composeFile) {
|
||||
return res.status(400).json({ message: "Tüm alanlar gerekli" });
|
||||
}
|
||||
@@ -138,8 +162,13 @@ router.post("/", async (req, res) => {
|
||||
repoUrl,
|
||||
branch,
|
||||
composeFile,
|
||||
port
|
||||
port,
|
||||
envContent,
|
||||
envExampleName
|
||||
});
|
||||
deploymentService
|
||||
.runDeployment(created._id.toString(), { message: "First deployment" })
|
||||
.catch(() => undefined);
|
||||
return res.status(201).json(created);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message });
|
||||
@@ -150,7 +179,7 @@ router.post("/", async (req, res) => {
|
||||
router.put("/:id", async (req, res) => {
|
||||
authMiddleware(req, res, async () => {
|
||||
const { id } = req.params;
|
||||
const { name, repoUrl, branch, composeFile, port } = req.body;
|
||||
const { name, repoUrl, branch, composeFile, port, envContent, envExampleName } = req.body;
|
||||
if (!name || !repoUrl || !branch || !composeFile) {
|
||||
return res.status(400).json({ message: "Tüm alanlar gerekli" });
|
||||
}
|
||||
@@ -160,7 +189,9 @@ router.put("/:id", async (req, res) => {
|
||||
repoUrl,
|
||||
branch,
|
||||
composeFile,
|
||||
port
|
||||
port,
|
||||
envContent,
|
||||
envExampleName
|
||||
});
|
||||
if (!updated) return res.status(404).json({ message: "Deployment bulunamadı" });
|
||||
return res.json(updated);
|
||||
@@ -174,9 +205,12 @@ router.delete("/:id", async (req, res) => {
|
||||
authMiddleware(req, res, async () => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const deleted = await DeploymentProject.findByIdAndDelete(id);
|
||||
if (!deleted) return res.status(404).json({ message: "Deployment bulunamadı" });
|
||||
const project = await DeploymentProject.findById(id);
|
||||
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
|
||||
await deploymentService.cleanupProjectResources(project);
|
||||
await DeploymentProject.findByIdAndDelete(id);
|
||||
await DeploymentRun.deleteMany({ project: id });
|
||||
await fs.promises.rm(project.rootPath, { recursive: true, force: true });
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message });
|
||||
@@ -189,7 +223,25 @@ router.post("/:id/run", async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const project = await DeploymentProject.findById(id);
|
||||
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
|
||||
deploymentService.runDeployment(id).catch(() => undefined);
|
||||
const rawMessage = typeof req.body?.message === "string" ? req.body.message.trim() : "";
|
||||
const message = rawMessage || "manual deploy trigger";
|
||||
deploymentService
|
||||
.runDeployment(id, { message })
|
||||
.catch(() => undefined);
|
||||
return res.json({ queued: true });
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/:id/restart", async (req, res) => {
|
||||
authMiddleware(req, res, async () => {
|
||||
const { id } = req.params;
|
||||
const project = await DeploymentProject.findById(id);
|
||||
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
|
||||
const rawMessage = typeof req.body?.message === "string" ? req.body.message.trim() : "";
|
||||
const message = rawMessage || "restart";
|
||||
deploymentService
|
||||
.restartDeployment(id, { message })
|
||||
.catch(() => undefined);
|
||||
return res.json({ queued: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,11 @@ const router = Router();
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
const jobs = await Job.find().sort({ createdAt: -1 }).lean();
|
||||
let jobs = await Job.find().sort({ createdAt: -1 }).lean();
|
||||
if (jobs.length === 0) {
|
||||
await jobService.bootstrapFromFilesystem();
|
||||
jobs = await Job.find().sort({ createdAt: -1 }).lean();
|
||||
}
|
||||
const counts = await JobRun.aggregate([
|
||||
{ $group: { _id: "$job", runCount: { $sum: 1 } } }
|
||||
]);
|
||||
@@ -26,6 +30,10 @@ router.get("/", async (_req, res) => {
|
||||
});
|
||||
|
||||
router.get("/metrics/summary", async (_req, res) => {
|
||||
const jobCount = await Job.countDocuments();
|
||||
if (jobCount === 0) {
|
||||
await jobService.bootstrapFromFilesystem();
|
||||
}
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - 7);
|
||||
|
||||
@@ -87,6 +95,7 @@ router.post("/", async (req, res) => {
|
||||
}
|
||||
try {
|
||||
const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit });
|
||||
await jobService.persistMetadata(job);
|
||||
jobService.scheduleJob(job);
|
||||
// Yeni job oluşturulduğunda ilk test otomatik tetiklensin
|
||||
jobService.runJob(job._id.toString()).catch(() => undefined);
|
||||
@@ -106,6 +115,7 @@ router.put("/:id", async (req, res) => {
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
if (!job) return res.status(404).json({ message: "Job bulunamadı" });
|
||||
await jobService.persistMetadata(job);
|
||||
jobService.scheduleJob(job);
|
||||
return res.json(job);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router, Request } from "express";
|
||||
import crypto from "crypto";
|
||||
import { deploymentService } from "../services/deploymentService.js";
|
||||
import { deploymentService, normalizeCommitMessage } from "../services/deploymentService.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -18,6 +18,12 @@ function verifySignature(rawBody: Buffer, secret: string, signature: string) {
|
||||
return crypto.timingSafeEqual(Buffer.from(cleaned), Buffer.from(expected));
|
||||
}
|
||||
|
||||
function normalizeBranch(value: string | undefined) {
|
||||
const raw = (value || "").trim();
|
||||
if (!raw) return "";
|
||||
return raw.startsWith("refs/heads/") ? raw.replace("refs/heads/", "") : raw;
|
||||
}
|
||||
|
||||
router.post("/api/deployments/webhook/:token", async (req, res) => {
|
||||
const { token } = req.params;
|
||||
const settings = await deploymentService.ensureSettings();
|
||||
@@ -46,14 +52,16 @@ router.post("/api/deployments/webhook/:token", async (req, res) => {
|
||||
|
||||
const payload = req.body as { ref?: string; head_commit?: { message?: string }; commits?: Array<{ message?: string }> };
|
||||
const ref = payload?.ref || "";
|
||||
const branch = ref.startsWith("refs/heads/") ? ref.replace("refs/heads/", "") : ref;
|
||||
const commitMessage =
|
||||
const branch = normalizeBranch(ref);
|
||||
const commitMessageRaw =
|
||||
payload?.head_commit?.message || payload?.commits?.[payload.commits.length - 1]?.message;
|
||||
const commitMessage = normalizeCommitMessage(commitMessageRaw);
|
||||
|
||||
const project = await deploymentService.findByWebhookToken(token);
|
||||
if (!project) return res.status(404).json({ message: "Deployment bulunamadi" });
|
||||
|
||||
if (branch && branch !== project.branch) {
|
||||
const projectBranch = normalizeBranch(project.branch);
|
||||
if (projectBranch && projectBranch !== "*" && branch && branch !== projectBranch) {
|
||||
return res.json({ ignored: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,199 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import { spawn } from "child_process";
|
||||
import { Server } from "socket.io";
|
||||
import { config } from "../config/env.js";
|
||||
import {
|
||||
DeploymentProject,
|
||||
DeploymentProjectDocument,
|
||||
ComposeFile,
|
||||
DeploymentEnv
|
||||
} from "../models/deploymentProject.js";
|
||||
import { DeploymentRun } from "../models/deploymentRun.js";
|
||||
import { DeploymentRun, DeploymentRunDocument } from "../models/deploymentRun.js";
|
||||
import { Settings } from "../models/settings.js";
|
||||
|
||||
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
|
||||
|
||||
const deploymentsRoot = "/workspace/deployments";
|
||||
const deploymentsRoot = config.deploymentsRoot;
|
||||
const metadataFileName = ".wisecolt-ci.json";
|
||||
const settingsFileName = ".wisecolt-ci-settings.json";
|
||||
const runsDirName = ".wisecolt-ci-runs";
|
||||
|
||||
export function normalizeCommitMessage(message?: string) {
|
||||
if (!message) return undefined;
|
||||
const firstLine = message.split(/\r?\n/)[0]?.trim();
|
||||
return firstLine || undefined;
|
||||
}
|
||||
|
||||
type DeploymentMetadata = {
|
||||
name: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composeFile: ComposeFile;
|
||||
webhookToken: string;
|
||||
env: DeploymentEnv;
|
||||
port?: number;
|
||||
envContent?: string;
|
||||
envExampleName?: string;
|
||||
};
|
||||
|
||||
type SettingsMetadata = {
|
||||
webhookToken: string;
|
||||
webhookSecret: string;
|
||||
};
|
||||
|
||||
type StoredRun = {
|
||||
status: "running" | "success" | "failed";
|
||||
message?: string;
|
||||
logs: string[];
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
durationMs?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
async function readMetadata(repoDir: string): Promise<DeploymentMetadata | null> {
|
||||
const filePath = path.join(repoDir, metadataFileName);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as DeploymentMetadata;
|
||||
if (!parsed?.repoUrl || !parsed?.composeFile) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeMetadata(repoDir: string, data: DeploymentMetadata) {
|
||||
const filePath = path.join(repoDir, metadataFileName);
|
||||
const payload = JSON.stringify(data, null, 2);
|
||||
await fs.promises.writeFile(filePath, payload, "utf8");
|
||||
}
|
||||
|
||||
function getRunsDir(repoDir: string) {
|
||||
return path.join(repoDir, runsDirName);
|
||||
}
|
||||
|
||||
function serializeRun(run: DeploymentRunDocument) {
|
||||
return {
|
||||
status: run.status,
|
||||
message: run.message,
|
||||
logs: run.logs || [],
|
||||
startedAt: new Date(run.startedAt).toISOString(),
|
||||
finishedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : undefined,
|
||||
durationMs: run.durationMs,
|
||||
createdAt: new Date(run.createdAt).toISOString(),
|
||||
updatedAt: new Date(run.updatedAt).toISOString()
|
||||
} satisfies StoredRun;
|
||||
}
|
||||
|
||||
async function writeRunFile(repoDir: string, run: DeploymentRunDocument) {
|
||||
const dir = getRunsDir(repoDir);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
const data = serializeRun(run);
|
||||
const name = `${new Date(data.startedAt).getTime()}-${run._id.toString()}.json`;
|
||||
const filePath = path.join(dir, name);
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
async function readStoredRuns(repoDir: string): Promise<StoredRun[]> {
|
||||
const dir = getRunsDir(repoDir);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const entries = await fs.promises.readdir(dir);
|
||||
const items: StoredRun[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(".json")) continue;
|
||||
try {
|
||||
const raw = await fs.promises.readFile(path.join(dir, entry), "utf8");
|
||||
const parsed = JSON.parse(raw) as StoredRun;
|
||||
if (!parsed?.startedAt || !parsed?.status) continue;
|
||||
items.push(parsed);
|
||||
} catch {
|
||||
// ignore invalid file
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function readSettingsFile(): Promise<SettingsMetadata | null> {
|
||||
const filePath = path.join(deploymentsRoot, settingsFileName);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as SettingsMetadata;
|
||||
if (!parsed?.webhookToken || !parsed?.webhookSecret) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSettingsFile(data: SettingsMetadata) {
|
||||
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
|
||||
const filePath = path.join(deploymentsRoot, settingsFileName);
|
||||
const payload = JSON.stringify(data, null, 2);
|
||||
await fs.promises.writeFile(filePath, payload, "utf8");
|
||||
}
|
||||
|
||||
function inferComposeFile(repoDir: string): ComposeFile | null {
|
||||
const prod = path.join(repoDir, "docker-compose.yml");
|
||||
if (fs.existsSync(prod)) return "docker-compose.yml";
|
||||
const dev = path.join(repoDir, "docker-compose.dev.yml");
|
||||
if (fs.existsSync(dev)) return "docker-compose.dev.yml";
|
||||
return null;
|
||||
}
|
||||
|
||||
async function inferRepoUrlFromGit(repoDir: string): Promise<string | null> {
|
||||
const gitConfig = path.join(repoDir, ".git", "config");
|
||||
if (!fs.existsSync(gitConfig)) return null;
|
||||
try {
|
||||
const content = await fs.promises.readFile(gitConfig, "utf8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
let inOrigin = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("[remote \"")) {
|
||||
inOrigin = trimmed === "[remote \"origin\"]";
|
||||
continue;
|
||||
}
|
||||
if (!inOrigin) continue;
|
||||
if (trimmed.startsWith("url")) {
|
||||
const parts = trimmed.split("=");
|
||||
const value = parts.slice(1).join("=").trim();
|
||||
return value || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function inferBranchFromGit(repoDir: string): Promise<string | null> {
|
||||
const headPath = path.join(repoDir, ".git", "HEAD");
|
||||
if (!fs.existsSync(headPath)) return null;
|
||||
try {
|
||||
const head = (await fs.promises.readFile(headPath, "utf8")).trim();
|
||||
if (!head.startsWith("ref:")) return null;
|
||||
const ref = head.replace("ref:", "").trim();
|
||||
const prefix = "refs/heads/";
|
||||
if (ref.startsWith(prefix)) {
|
||||
return ref.slice(prefix.length);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function inferName(repoUrl: string, rootPath: string) {
|
||||
const normalized = repoUrl.replace(/\/+$/, "");
|
||||
const lastPart = normalized.split("/").pop() || "";
|
||||
const cleaned = lastPart.replace(/\.git$/i, "");
|
||||
return cleaned || path.basename(rootPath);
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
@@ -118,11 +299,37 @@ async function ensureRepo(project: DeploymentProjectDocument, onData: (line: str
|
||||
|
||||
if (!exists) {
|
||||
const entries = await fs.promises.readdir(repoDir);
|
||||
if (entries.length > 0) {
|
||||
const allowed = new Set<string>([metadataFileName, ".env", ".env.local", runsDirName]);
|
||||
const blocking = entries.filter((name) => !allowed.has(name));
|
||||
if (blocking.length > 0) {
|
||||
throw new Error("Repo klasoru git olmayan dosyalar iceriyor");
|
||||
}
|
||||
let envBackup: string | null = null;
|
||||
const envPath = path.join(repoDir, ".env");
|
||||
if (fs.existsSync(envPath)) {
|
||||
envBackup = await fs.promises.readFile(envPath, "utf8");
|
||||
}
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((name) => allowed.has(name))
|
||||
.map((name) => fs.promises.rm(path.join(repoDir, name), { force: true }))
|
||||
);
|
||||
onData(`Repo klonlanıyor: ${project.repoUrl}`);
|
||||
await runCommand(`git clone --branch ${project.branch} ${project.repoUrl} .`, repoDir, onData);
|
||||
if (envBackup) {
|
||||
await fs.promises.writeFile(envPath, envBackup, "utf8");
|
||||
}
|
||||
await writeMetadata(repoDir, {
|
||||
name: project.name,
|
||||
repoUrl: project.repoUrl,
|
||||
branch: project.branch,
|
||||
composeFile: project.composeFile,
|
||||
webhookToken: project.webhookToken,
|
||||
env: project.env,
|
||||
port: project.port,
|
||||
envContent: project.envContent,
|
||||
envExampleName: project.envExampleName
|
||||
});
|
||||
} else {
|
||||
onData("Repo güncelleniyor (git fetch/pull)...");
|
||||
await runCommand(`git fetch origin ${project.branch}`, repoDir, onData);
|
||||
@@ -152,6 +359,39 @@ async function runCompose(project: DeploymentProjectDocument, onData: (line: str
|
||||
|
||||
class DeploymentService {
|
||||
private running: Map<string, boolean> = new Map();
|
||||
private io: Server | null = null;
|
||||
|
||||
setSocket(io: Server) {
|
||||
this.io = io;
|
||||
}
|
||||
|
||||
private async emitStatus(deploymentId: string, payload: Partial<DeploymentProjectDocument>) {
|
||||
if (!this.io) return;
|
||||
const runCount = await DeploymentRun.countDocuments({ project: deploymentId });
|
||||
const body = {
|
||||
deploymentId,
|
||||
status: payload.lastStatus,
|
||||
lastRunAt: payload.lastDeployAt,
|
||||
lastMessage: payload.lastMessage,
|
||||
runCount
|
||||
};
|
||||
this.io.to(`deployment:${deploymentId}`).emit("deployment:status", body);
|
||||
this.io.emit("deployment:status", body);
|
||||
}
|
||||
|
||||
private emitLog(deploymentId: string, line: string) {
|
||||
if (!this.io) return;
|
||||
this.io.to(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
|
||||
this.io.except(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
|
||||
}
|
||||
|
||||
private emitRun(deploymentId: string, run: DeploymentRunDocument) {
|
||||
if (!this.io) return;
|
||||
this.io.to(`deployment:${deploymentId}`).emit("deployment:run", {
|
||||
deploymentId,
|
||||
run
|
||||
});
|
||||
}
|
||||
|
||||
async listRemoteBranches(repoUrl: string) {
|
||||
const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd());
|
||||
@@ -183,13 +423,53 @@ class DeploymentService {
|
||||
}
|
||||
}
|
||||
|
||||
async listRemoteEnvExamples(repoUrl: string, branch: string) {
|
||||
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
|
||||
const tmpBase = await fs.promises.mkdtemp(path.join(deploymentsRoot, ".tmp-"));
|
||||
try {
|
||||
await runCommand(
|
||||
`git clone --depth 1 --single-branch --branch ${branch} ${repoUrl} ${tmpBase}`,
|
||||
process.cwd(),
|
||||
() => undefined
|
||||
);
|
||||
const entries = await fs.promises.readdir(tmpBase, { withFileTypes: true });
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => name.toLowerCase().endsWith(".env.example"));
|
||||
const items = await Promise.all(
|
||||
files.map(async (name) => ({
|
||||
name,
|
||||
content: await fs.promises.readFile(path.join(tmpBase, name), "utf8")
|
||||
}))
|
||||
);
|
||||
return items;
|
||||
} finally {
|
||||
await fs.promises.rm(tmpBase, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async ensureSettings() {
|
||||
const existing = await Settings.findOne();
|
||||
if (existing) return existing;
|
||||
|
||||
const fileSettings = await readSettingsFile();
|
||||
if (fileSettings) {
|
||||
const createdFromFile = await Settings.create({
|
||||
webhookToken: fileSettings.webhookToken,
|
||||
webhookSecret: fileSettings.webhookSecret
|
||||
});
|
||||
return createdFromFile;
|
||||
}
|
||||
|
||||
const created = await Settings.create({
|
||||
webhookToken: generateApiToken(),
|
||||
webhookSecret: generateSecret()
|
||||
});
|
||||
await writeSettingsFile({
|
||||
webhookToken: created.webhookToken,
|
||||
webhookSecret: created.webhookSecret
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -197,6 +477,10 @@ class DeploymentService {
|
||||
const settings = await this.ensureSettings();
|
||||
settings.webhookToken = generateApiToken();
|
||||
await settings.save();
|
||||
await writeSettingsFile({
|
||||
webhookToken: settings.webhookToken,
|
||||
webhookSecret: settings.webhookSecret
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
|
||||
@@ -204,6 +488,10 @@ class DeploymentService {
|
||||
const settings = await this.ensureSettings();
|
||||
settings.webhookSecret = generateSecret();
|
||||
await settings.save();
|
||||
await writeSettingsFile({
|
||||
webhookToken: settings.webhookToken,
|
||||
webhookSecret: settings.webhookSecret
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
|
||||
@@ -213,6 +501,8 @@ class DeploymentService {
|
||||
branch: string;
|
||||
composeFile: ComposeFile;
|
||||
port?: number;
|
||||
envContent?: string;
|
||||
envExampleName?: string;
|
||||
}) {
|
||||
const repoUrl = normalizeRepoUrl(input.repoUrl);
|
||||
const existingRepo = await DeploymentProject.findOne({ repoUrl });
|
||||
@@ -238,7 +528,7 @@ class DeploymentService {
|
||||
}
|
||||
|
||||
const env = deriveEnv(input.composeFile);
|
||||
return DeploymentProject.create({
|
||||
const created = await DeploymentProject.create({
|
||||
name: input.name,
|
||||
rootPath,
|
||||
repoUrl,
|
||||
@@ -246,8 +536,22 @@ class DeploymentService {
|
||||
composeFile: input.composeFile,
|
||||
webhookToken,
|
||||
env,
|
||||
port: input.port
|
||||
port: input.port,
|
||||
envContent: input.envContent,
|
||||
envExampleName: input.envExampleName
|
||||
});
|
||||
await writeMetadata(rootPath, {
|
||||
name: created.name,
|
||||
repoUrl: created.repoUrl,
|
||||
branch: created.branch,
|
||||
composeFile: created.composeFile,
|
||||
webhookToken: created.webhookToken,
|
||||
env: created.env,
|
||||
port: created.port,
|
||||
envContent: created.envContent,
|
||||
envExampleName: created.envExampleName
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateProject(
|
||||
@@ -258,6 +562,8 @@ class DeploymentService {
|
||||
branch: string;
|
||||
composeFile: ComposeFile;
|
||||
port?: number;
|
||||
envContent?: string;
|
||||
envExampleName?: string;
|
||||
}
|
||||
) {
|
||||
const project = await DeploymentProject.findById(id);
|
||||
@@ -282,10 +588,25 @@ class DeploymentService {
|
||||
branch: input.branch,
|
||||
composeFile: input.composeFile,
|
||||
env,
|
||||
port: input.port
|
||||
port: input.port,
|
||||
envContent: input.envContent,
|
||||
envExampleName: input.envExampleName
|
||||
},
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
if (updated) {
|
||||
await writeMetadata(updated.rootPath, {
|
||||
name: updated.name,
|
||||
repoUrl: updated.repoUrl,
|
||||
branch: updated.branch,
|
||||
composeFile: updated.composeFile,
|
||||
webhookToken: updated.webhookToken,
|
||||
env: updated.env,
|
||||
port: updated.port,
|
||||
envContent: updated.envContent,
|
||||
envExampleName: updated.envExampleName
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -301,41 +622,61 @@ class DeploymentService {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedMessage = normalizeCommitMessage(options?.message);
|
||||
const startedAt = Date.now();
|
||||
const runLogs: string[] = [];
|
||||
const pushLog = (line: string) => {
|
||||
runLogs.push(line);
|
||||
this.emitLog(projectId, line);
|
||||
};
|
||||
|
||||
const runDoc = await DeploymentRun.create({
|
||||
project: projectId,
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
message: options?.message
|
||||
message: normalizedMessage ?? options?.message
|
||||
});
|
||||
this.emitRun(projectId, runDoc);
|
||||
await writeRunFile(project.rootPath, runDoc);
|
||||
|
||||
await DeploymentProject.findByIdAndUpdate(projectId, {
|
||||
lastStatus: "running",
|
||||
lastMessage: options?.message || "Deploy başlıyor..."
|
||||
lastMessage: normalizedMessage ?? options?.message ?? "Deploy başlıyor..."
|
||||
});
|
||||
await this.emitStatus(projectId, {
|
||||
lastStatus: "running",
|
||||
lastMessage: normalizedMessage ?? options?.message ?? "Deploy başlıyor..."
|
||||
} as DeploymentProjectDocument);
|
||||
|
||||
try {
|
||||
await ensureRepo(project, (line) => pushLog(line));
|
||||
if (project.envContent) {
|
||||
await fs.promises.writeFile(path.join(project.rootPath, ".env"), project.envContent, "utf8");
|
||||
pushLog(".env güncellendi");
|
||||
}
|
||||
pushLog("Deploy komutları çalıştırılıyor...");
|
||||
await runCompose(project, (line) => pushLog(line));
|
||||
const duration = Date.now() - startedAt;
|
||||
await DeploymentProject.findByIdAndUpdate(projectId, {
|
||||
lastStatus: "success",
|
||||
lastDeployAt: new Date(),
|
||||
lastMessage: options?.message || "Başarılı"
|
||||
lastMessage: normalizedMessage ?? options?.message ?? "Başarılı"
|
||||
});
|
||||
await this.emitStatus(projectId, {
|
||||
lastStatus: "success",
|
||||
lastDeployAt: new Date(),
|
||||
lastMessage: normalizedMessage ?? options?.message ?? "Başarılı"
|
||||
} as DeploymentProjectDocument);
|
||||
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
||||
status: "success",
|
||||
finishedAt: new Date(),
|
||||
durationMs: duration,
|
||||
logs: runLogs,
|
||||
message: options?.message
|
||||
message: normalizedMessage ?? options?.message
|
||||
});
|
||||
const updatedRun = await DeploymentRun.findById(runDoc._id);
|
||||
if (updatedRun) this.emitRun(projectId, updatedRun);
|
||||
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
|
||||
pushLog("Deploy tamamlandı: Başarılı");
|
||||
} catch (err) {
|
||||
const duration = Date.now() - startedAt;
|
||||
@@ -344,22 +685,258 @@ class DeploymentService {
|
||||
lastDeployAt: new Date(),
|
||||
lastMessage: (err as Error).message
|
||||
});
|
||||
await this.emitStatus(projectId, {
|
||||
lastStatus: "failed",
|
||||
lastDeployAt: new Date(),
|
||||
lastMessage: (err as Error).message
|
||||
} as DeploymentProjectDocument);
|
||||
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
||||
status: "failed",
|
||||
finishedAt: new Date(),
|
||||
durationMs: duration,
|
||||
logs: runLogs,
|
||||
message: options?.message
|
||||
message: normalizedMessage ?? options?.message
|
||||
});
|
||||
const updatedRun = await DeploymentRun.findById(runDoc._id);
|
||||
if (updatedRun) this.emitRun(projectId, updatedRun);
|
||||
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
|
||||
pushLog(`Hata: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.running.delete(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
async restartDeployment(projectId: string, options?: { message?: string }) {
|
||||
if (this.running.get(projectId)) {
|
||||
return;
|
||||
}
|
||||
this.running.set(projectId, true);
|
||||
|
||||
const project = await DeploymentProject.findById(projectId);
|
||||
if (!project) {
|
||||
this.running.delete(projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedMessage = normalizeCommitMessage(options?.message);
|
||||
const startedAt = Date.now();
|
||||
const runLogs: string[] = [];
|
||||
const pushLog = (line: string) => {
|
||||
runLogs.push(line);
|
||||
this.emitLog(projectId, line);
|
||||
};
|
||||
|
||||
const runDoc = await DeploymentRun.create({
|
||||
project: projectId,
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
message: normalizedMessage ?? options?.message
|
||||
});
|
||||
this.emitRun(projectId, runDoc);
|
||||
await writeRunFile(project.rootPath, runDoc);
|
||||
|
||||
await DeploymentProject.findByIdAndUpdate(projectId, {
|
||||
lastStatus: "running",
|
||||
lastMessage: normalizedMessage ?? options?.message ?? "Restart başlıyor..."
|
||||
});
|
||||
await this.emitStatus(projectId, {
|
||||
lastStatus: "running",
|
||||
lastMessage: normalizedMessage ?? options?.message ?? "Restart başlıyor..."
|
||||
} as DeploymentProjectDocument);
|
||||
|
||||
try {
|
||||
pushLog("Restart komutları çalıştırılıyor...");
|
||||
await runCompose(project, (line) => pushLog(line));
|
||||
const duration = Date.now() - startedAt;
|
||||
await DeploymentProject.findByIdAndUpdate(projectId, {
|
||||
lastStatus: "success",
|
||||
lastDeployAt: new Date(),
|
||||
lastMessage: normalizedMessage ?? options?.message ?? "Restart başarılı"
|
||||
});
|
||||
await this.emitStatus(projectId, {
|
||||
lastStatus: "success",
|
||||
lastDeployAt: new Date(),
|
||||
lastMessage: normalizedMessage ?? options?.message ?? "Restart başarılı"
|
||||
} as DeploymentProjectDocument);
|
||||
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
||||
status: "success",
|
||||
finishedAt: new Date(),
|
||||
durationMs: duration,
|
||||
logs: runLogs,
|
||||
message: normalizedMessage ?? options?.message
|
||||
});
|
||||
const updatedRun = await DeploymentRun.findById(runDoc._id);
|
||||
if (updatedRun) this.emitRun(projectId, updatedRun);
|
||||
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
|
||||
pushLog("Restart tamamlandı: Başarılı");
|
||||
} catch (err) {
|
||||
const duration = Date.now() - startedAt;
|
||||
await DeploymentProject.findByIdAndUpdate(projectId, {
|
||||
lastStatus: "failed",
|
||||
lastDeployAt: new Date(),
|
||||
lastMessage: (err as Error).message
|
||||
});
|
||||
await this.emitStatus(projectId, {
|
||||
lastStatus: "failed",
|
||||
lastDeployAt: new Date(),
|
||||
lastMessage: (err as Error).message
|
||||
} as DeploymentProjectDocument);
|
||||
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
||||
status: "failed",
|
||||
finishedAt: new Date(),
|
||||
durationMs: duration,
|
||||
logs: runLogs,
|
||||
message: normalizedMessage ?? options?.message
|
||||
});
|
||||
const updatedRun = await DeploymentRun.findById(runDoc._id);
|
||||
if (updatedRun) this.emitRun(projectId, updatedRun);
|
||||
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
|
||||
pushLog(`Hata: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.running.delete(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupProjectResources(project: DeploymentProjectDocument) {
|
||||
const composePath = path.join(project.rootPath, project.composeFile);
|
||||
if (!fs.existsSync(composePath)) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
`docker compose -f ${project.composeFile} down --remove-orphans -v --rmi local`,
|
||||
project.rootPath,
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
|
||||
async findByWebhookToken(token: string) {
|
||||
return DeploymentProject.findOne({ webhookToken: token });
|
||||
}
|
||||
|
||||
async normalizeExistingCommitMessages() {
|
||||
const projects = await DeploymentProject.find({
|
||||
lastMessage: { $regex: /[\r\n]/ }
|
||||
});
|
||||
for (const project of projects) {
|
||||
const normalized = normalizeCommitMessage(project.lastMessage);
|
||||
if (normalized && normalized !== project.lastMessage) {
|
||||
project.lastMessage = normalized;
|
||||
await project.save();
|
||||
}
|
||||
}
|
||||
|
||||
const runs = await DeploymentRun.find({
|
||||
message: { $regex: /[\r\n]/ }
|
||||
});
|
||||
for (const run of runs) {
|
||||
const normalized = normalizeCommitMessage(run.message);
|
||||
if (normalized && normalized !== run.message) {
|
||||
run.message = normalized;
|
||||
await run.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async bootstrapFromFilesystem() {
|
||||
const candidateRoots = [
|
||||
deploymentsRoot,
|
||||
path.resolve(process.cwd(), "deployments"),
|
||||
path.resolve(process.cwd(), "..", "deployments"),
|
||||
path.resolve(process.cwd(), "..", "..", "deployments"),
|
||||
"/root/Wisecolt-CI/deployments"
|
||||
];
|
||||
const roots = Array.from(
|
||||
new Set(candidateRoots.filter((root) => root && fs.existsSync(root)))
|
||||
);
|
||||
|
||||
for (const root of roots) {
|
||||
const entries = await fs.promises.readdir(root, { withFileTypes: true });
|
||||
const dirs = entries.filter((entry) => entry.isDirectory());
|
||||
|
||||
for (const entry of dirs) {
|
||||
const rootPath = path.join(root, entry.name);
|
||||
const existing = await DeploymentProject.findOne({ rootPath });
|
||||
if (existing) continue;
|
||||
|
||||
const metadata = await readMetadata(rootPath);
|
||||
const repoUrlRaw = metadata?.repoUrl || (await inferRepoUrlFromGit(rootPath));
|
||||
if (!repoUrlRaw) continue;
|
||||
const repoUrl = normalizeRepoUrl(repoUrlRaw);
|
||||
const repoExisting = await DeploymentProject.findOne({ repoUrl });
|
||||
if (repoExisting) continue;
|
||||
|
||||
const composeFile = metadata?.composeFile || inferComposeFile(rootPath);
|
||||
if (!composeFile) continue;
|
||||
const branch = metadata?.branch || (await inferBranchFromGit(rootPath)) || "main";
|
||||
const name = metadata?.name || inferName(repoUrl, rootPath);
|
||||
|
||||
let webhookToken = metadata?.webhookToken || generateWebhookToken();
|
||||
while (await DeploymentProject.findOne({ webhookToken })) {
|
||||
webhookToken = generateWebhookToken();
|
||||
}
|
||||
|
||||
let envContent = metadata?.envContent;
|
||||
const envPath = path.join(rootPath, ".env");
|
||||
if (!envContent && fs.existsSync(envPath)) {
|
||||
envContent = await fs.promises.readFile(envPath, "utf8");
|
||||
}
|
||||
|
||||
const envExampleName = metadata?.envExampleName;
|
||||
const env = deriveEnv(composeFile);
|
||||
|
||||
const created = await DeploymentProject.create({
|
||||
name,
|
||||
rootPath,
|
||||
repoUrl,
|
||||
branch,
|
||||
composeFile,
|
||||
webhookToken,
|
||||
env,
|
||||
port: metadata?.port,
|
||||
envContent,
|
||||
envExampleName
|
||||
});
|
||||
|
||||
await writeMetadata(rootPath, {
|
||||
name: created.name,
|
||||
repoUrl: created.repoUrl,
|
||||
branch: created.branch,
|
||||
composeFile: created.composeFile,
|
||||
webhookToken: created.webhookToken,
|
||||
env: created.env,
|
||||
port: created.port,
|
||||
envContent: created.envContent,
|
||||
envExampleName: created.envExampleName
|
||||
});
|
||||
|
||||
const storedRuns = await readStoredRuns(rootPath);
|
||||
if (storedRuns.length > 0) {
|
||||
storedRuns.sort(
|
||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||
);
|
||||
await DeploymentRun.insertMany(
|
||||
storedRuns.map((run) => ({
|
||||
project: created._id,
|
||||
status: run.status,
|
||||
message: run.message,
|
||||
logs: run.logs || [],
|
||||
startedAt: new Date(run.startedAt),
|
||||
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
|
||||
durationMs: run.durationMs,
|
||||
createdAt: new Date(run.createdAt),
|
||||
updatedAt: new Date(run.updatedAt)
|
||||
}))
|
||||
);
|
||||
const latest = storedRuns[0];
|
||||
await DeploymentProject.findByIdAndUpdate(created._id, {
|
||||
lastStatus: latest.status,
|
||||
lastDeployAt: new Date(latest.finishedAt || latest.startedAt),
|
||||
lastMessage: latest.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const deploymentService = new DeploymentService();
|
||||
|
||||
@@ -3,9 +3,11 @@ import path from "path";
|
||||
import { spawn } from "child_process";
|
||||
import { Server } from "socket.io";
|
||||
import { Job, JobDocument, TimeUnit } from "../models/job.js";
|
||||
import { JobRun } from "../models/jobRun.js";
|
||||
import { JobRun, JobRunDocument } from "../models/jobRun.js";
|
||||
|
||||
const repoBaseDir = path.join(process.cwd(), "test-runs");
|
||||
const jobMetadataFileName = ".wisecolt-ci-job.json";
|
||||
const jobRunsDirName = ".wisecolt-ci-job-runs";
|
||||
|
||||
function unitToMs(unit: TimeUnit) {
|
||||
if (unit === "dakika") return 60_000;
|
||||
@@ -17,6 +19,91 @@ function ensureDir(dir: string) {
|
||||
return fs.promises.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
type JobMetadata = {
|
||||
name: string;
|
||||
repoUrl: string;
|
||||
testCommand: string;
|
||||
checkValue: number;
|
||||
checkUnit: TimeUnit;
|
||||
};
|
||||
|
||||
type StoredJobRun = {
|
||||
status: "running" | "success" | "failed";
|
||||
logs: string[];
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
durationMs?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function getJobDir(jobId: string) {
|
||||
return path.join(repoBaseDir, jobId);
|
||||
}
|
||||
|
||||
function getJobRunsDir(jobDir: string) {
|
||||
return path.join(jobDir, jobRunsDirName);
|
||||
}
|
||||
|
||||
async function readJobMetadata(jobDir: string): Promise<JobMetadata | null> {
|
||||
const filePath = path.join(jobDir, jobMetadataFileName);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as JobMetadata;
|
||||
if (!parsed?.repoUrl || !parsed?.testCommand) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJobMetadata(jobDir: string, data: JobMetadata) {
|
||||
await ensureDir(jobDir);
|
||||
const filePath = path.join(jobDir, jobMetadataFileName);
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function serializeJobRun(run: JobRunDocument) {
|
||||
return {
|
||||
status: run.status,
|
||||
logs: run.logs || [],
|
||||
startedAt: new Date(run.startedAt).toISOString(),
|
||||
finishedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : undefined,
|
||||
durationMs: run.durationMs,
|
||||
createdAt: new Date(run.createdAt).toISOString(),
|
||||
updatedAt: new Date(run.updatedAt).toISOString()
|
||||
} satisfies StoredJobRun;
|
||||
}
|
||||
|
||||
async function writeJobRunFile(jobDir: string, run: JobRunDocument) {
|
||||
const dir = getJobRunsDir(jobDir);
|
||||
await ensureDir(dir);
|
||||
const data = serializeJobRun(run);
|
||||
const name = `${new Date(data.startedAt).getTime()}-${run._id.toString()}.json`;
|
||||
const filePath = path.join(dir, name);
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
async function readStoredJobRuns(jobDir: string): Promise<StoredJobRun[]> {
|
||||
const dir = getJobRunsDir(jobDir);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const entries = await fs.promises.readdir(dir);
|
||||
const items: StoredJobRun[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(".json")) continue;
|
||||
try {
|
||||
const raw = await fs.promises.readFile(path.join(dir, entry), "utf8");
|
||||
const parsed = JSON.parse(raw) as StoredJobRun;
|
||||
if (!parsed?.startedAt || !parsed?.status) continue;
|
||||
items.push(parsed);
|
||||
} catch {
|
||||
// ignore invalid file
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function cleanOutput(input: string) {
|
||||
// ANSI escape sequences temizleme
|
||||
return input.replace(
|
||||
@@ -85,8 +172,42 @@ async function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) {
|
||||
const exists = fs.existsSync(gitDir);
|
||||
|
||||
if (!exists) {
|
||||
const entries = await fs.promises.readdir(repoDir);
|
||||
const allowed = new Set<string>([jobMetadataFileName, jobRunsDirName]);
|
||||
const blocking = entries.filter((name) => !allowed.has(name));
|
||||
if (blocking.length > 0) {
|
||||
throw new Error("Repo klasoru git olmayan dosyalar iceriyor");
|
||||
}
|
||||
|
||||
let metadataBackup: string | null = null;
|
||||
const metadataPath = path.join(repoDir, jobMetadataFileName);
|
||||
if (fs.existsSync(metadataPath)) {
|
||||
metadataBackup = await fs.promises.readFile(metadataPath, "utf8");
|
||||
}
|
||||
|
||||
let runsBackupPath: string | null = null;
|
||||
const runsDir = path.join(repoDir, jobRunsDirName);
|
||||
if (fs.existsSync(runsDir)) {
|
||||
const tmpBase = await fs.promises.mkdtemp(path.join(repoBaseDir, ".tmp-"));
|
||||
runsBackupPath = path.join(tmpBase, jobRunsDirName);
|
||||
await fs.promises.rename(runsDir, runsBackupPath);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((name) => allowed.has(name))
|
||||
.map((name) => fs.promises.rm(path.join(repoDir, name), { recursive: true, force: true }))
|
||||
);
|
||||
|
||||
onData(`Repo klonlanıyor: ${job.repoUrl}`);
|
||||
await runCommand(`git clone ${job.repoUrl} ${repoDir}`, process.cwd(), onData);
|
||||
|
||||
if (metadataBackup) {
|
||||
await fs.promises.writeFile(metadataPath, metadataBackup, "utf8");
|
||||
}
|
||||
if (runsBackupPath) {
|
||||
await fs.promises.rename(runsBackupPath, runsDir);
|
||||
}
|
||||
} else {
|
||||
onData("Repo güncelleniyor (git pull)...");
|
||||
await runCommand("git pull", repoDir, onData);
|
||||
@@ -156,6 +277,7 @@ class JobService {
|
||||
status: "running",
|
||||
startedAt: new Date()
|
||||
});
|
||||
await writeJobRunFile(getJobDir(jobId), runDoc);
|
||||
|
||||
await Job.findByIdAndUpdate(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." });
|
||||
await this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument);
|
||||
@@ -179,6 +301,8 @@ class JobService {
|
||||
durationMs: duration,
|
||||
logs: runLogs
|
||||
});
|
||||
const updatedRun = await JobRun.findById(runDoc._id);
|
||||
if (updatedRun) await writeJobRunFile(getJobDir(jobId), updatedRun);
|
||||
await this.emitStatus(jobId, {
|
||||
status: "success",
|
||||
lastRunAt: new Date(),
|
||||
@@ -199,6 +323,8 @@ class JobService {
|
||||
durationMs: duration,
|
||||
logs: runLogs
|
||||
});
|
||||
const updatedRun = await JobRun.findById(runDoc._id);
|
||||
if (updatedRun) await writeJobRunFile(getJobDir(jobId), updatedRun);
|
||||
pushLog(`Hata: ${(err as Error).message}`);
|
||||
await this.emitStatus(jobId, {
|
||||
status: "failed",
|
||||
@@ -231,6 +357,78 @@ class JobService {
|
||||
const jobs = await Job.find();
|
||||
jobs.forEach((job) => this.scheduleJob(job));
|
||||
}
|
||||
|
||||
async persistMetadata(job: JobDocument) {
|
||||
await writeJobMetadata(getJobDir(job._id.toString()), {
|
||||
name: job.name,
|
||||
repoUrl: job.repoUrl,
|
||||
testCommand: job.testCommand,
|
||||
checkValue: job.checkValue,
|
||||
checkUnit: job.checkUnit
|
||||
});
|
||||
}
|
||||
|
||||
async bootstrapFromFilesystem() {
|
||||
const candidateRoots = [
|
||||
repoBaseDir,
|
||||
path.resolve(process.cwd(), "test-runs"),
|
||||
path.resolve(process.cwd(), "..", "test-runs"),
|
||||
path.resolve(process.cwd(), "..", "..", "test-runs"),
|
||||
"/root/Wisecolt-CI/test-runs"
|
||||
];
|
||||
const roots = Array.from(
|
||||
new Set(candidateRoots.filter((root) => root && fs.existsSync(root)))
|
||||
);
|
||||
|
||||
for (const root of roots) {
|
||||
const entries = await fs.promises.readdir(root, { withFileTypes: true });
|
||||
const dirs = entries.filter((entry) => entry.isDirectory());
|
||||
|
||||
for (const entry of dirs) {
|
||||
const jobDir = path.join(root, entry.name);
|
||||
const metadata = await readJobMetadata(jobDir);
|
||||
if (!metadata) continue;
|
||||
|
||||
const existing = await Job.findOne({ repoUrl: metadata.repoUrl });
|
||||
if (existing) continue;
|
||||
|
||||
const created = await Job.create({
|
||||
name: metadata.name,
|
||||
repoUrl: metadata.repoUrl,
|
||||
testCommand: metadata.testCommand,
|
||||
checkValue: metadata.checkValue,
|
||||
checkUnit: metadata.checkUnit
|
||||
});
|
||||
await this.persistMetadata(created);
|
||||
|
||||
const storedRuns = await readStoredJobRuns(jobDir);
|
||||
if (storedRuns.length > 0) {
|
||||
storedRuns.sort(
|
||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||
);
|
||||
await JobRun.insertMany(
|
||||
storedRuns.map((run) => ({
|
||||
job: created._id,
|
||||
status: run.status,
|
||||
logs: run.logs || [],
|
||||
startedAt: new Date(run.startedAt),
|
||||
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
|
||||
durationMs: run.durationMs,
|
||||
createdAt: new Date(run.createdAt),
|
||||
updatedAt: new Date(run.updatedAt)
|
||||
}))
|
||||
);
|
||||
const latest = storedRuns[0];
|
||||
await Job.findByIdAndUpdate(created._id, {
|
||||
status: latest.status === "running" ? "idle" : latest.status,
|
||||
lastRunAt: new Date(latest.finishedAt || latest.startedAt),
|
||||
lastDurationMs: latest.durationMs,
|
||||
lastMessage: latest.status === "success" ? "Başarılı" : "Hata"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const jobService = new JobService();
|
||||
|
||||
@@ -13,10 +13,12 @@ services:
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/node_modules
|
||||
- ${DEPLOYMENTS_ROOT_HOST}:/workspace
|
||||
- ${PWD}:${PWD}
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
- ./.env
|
||||
environment:
|
||||
DEPLOYMENTS_ROOT: ${PWD}/deployments
|
||||
ports:
|
||||
- "4000:4000"
|
||||
depends_on:
|
||||
@@ -29,7 +31,7 @@ services:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
env_file:
|
||||
- ./frontend/.env
|
||||
- ./.env
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
|
||||
@@ -3,10 +3,12 @@ services:
|
||||
build: ./backend
|
||||
command: npm run build && npm start
|
||||
volumes:
|
||||
- ${DEPLOYMENTS_ROOT_HOST}:/workspace
|
||||
- ${PWD}:${PWD}
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
- ./.env
|
||||
environment:
|
||||
DEPLOYMENTS_ROOT: ${PWD}/deployments
|
||||
ports:
|
||||
- "4000:4000"
|
||||
|
||||
@@ -17,7 +19,7 @@ services:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
env_file:
|
||||
- ./frontend/.env
|
||||
- ./.env
|
||||
environment:
|
||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS}
|
||||
ports:
|
||||
|
||||
@@ -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,7 @@
|
||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"axios": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface DeploymentProject {
|
||||
webhookToken: string;
|
||||
env: DeploymentEnv;
|
||||
port?: number;
|
||||
envContent?: string;
|
||||
envExampleName?: string;
|
||||
lastDeployAt?: string;
|
||||
lastStatus: DeploymentStatus;
|
||||
lastMessage?: string;
|
||||
@@ -60,6 +62,8 @@ export interface DeploymentInput {
|
||||
branch: string;
|
||||
composeFile: ComposeFile;
|
||||
port?: number;
|
||||
envContent?: string;
|
||||
envExampleName?: string;
|
||||
}
|
||||
|
||||
export async function fetchDeployments(): Promise<DeploymentProject[]> {
|
||||
@@ -88,8 +92,12 @@ export async function deleteDeployment(id: string): Promise<void> {
|
||||
await apiClient.delete(`/deployments/${id}`);
|
||||
}
|
||||
|
||||
export async function runDeployment(id: string): Promise<void> {
|
||||
await apiClient.post(`/deployments/${id}/run`);
|
||||
export async function runDeployment(id: string, message?: string): Promise<void> {
|
||||
await apiClient.post(`/deployments/${id}/run`, message ? { message } : {});
|
||||
}
|
||||
|
||||
export async function restartDeployment(id: string, message?: string): Promise<void> {
|
||||
await apiClient.post(`/deployments/${id}/restart`, message ? { message } : {});
|
||||
}
|
||||
|
||||
export async function fetchDeployment(id: string): Promise<DeploymentDetailResponse> {
|
||||
@@ -111,3 +119,13 @@ export async function fetchDeploymentComposeFiles(
|
||||
});
|
||||
return (data as { files: ComposeFile[] }).files;
|
||||
}
|
||||
|
||||
export async function fetchDeploymentEnvExamples(
|
||||
repoUrl: string,
|
||||
branch: string
|
||||
): Promise<Array<{ name: string; content: string }>> {
|
||||
const { data } = await apiClient.get("/deployments/env-examples", {
|
||||
params: { repoUrl, branch }
|
||||
});
|
||||
return (data as { examples: Array<{ name: string; content: string }> }).examples;
|
||||
}
|
||||
|
||||
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faArrowLeft, faCloudArrowUp, faCopy, faHistory } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faArrowLeft,
|
||||
faCloudArrowUp,
|
||||
faCopy,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faHistory,
|
||||
faRotate
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||
import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import {
|
||||
DeploymentInput,
|
||||
DeploymentProject,
|
||||
DeploymentRun,
|
||||
fetchDeployment,
|
||||
fetchDeploymentBranches,
|
||||
fetchDeploymentComposeFiles,
|
||||
fetchDeploymentEnvExamples,
|
||||
restartDeployment,
|
||||
runDeployment,
|
||||
updateDeployment
|
||||
} from "../api/deployments";
|
||||
import { useDeploymentStream } from "../providers/live-provider";
|
||||
import { useSocket } from "../providers/socket-provider";
|
||||
|
||||
type FormState = {
|
||||
_id?: string;
|
||||
name: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composeFile: DeploymentInput["composeFile"];
|
||||
port: string;
|
||||
};
|
||||
|
||||
type EnvExample = { name: string; content: string };
|
||||
|
||||
export function DeploymentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -15,6 +51,29 @@ export function DeploymentDetailPage() {
|
||||
const [runs, setRuns] = useState<DeploymentRun[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [triggering, setTriggering] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<FormState>({
|
||||
name: "",
|
||||
repoUrl: "",
|
||||
branch: "main",
|
||||
composeFile: "docker-compose.yml",
|
||||
port: ""
|
||||
});
|
||||
const [branchOptions, setBranchOptions] = useState<string[]>([]);
|
||||
const [branchLoading, setBranchLoading] = useState(false);
|
||||
const [composeOptions, setComposeOptions] = useState<DeploymentInput["composeFile"][]>([]);
|
||||
const [composeLoading, setComposeLoading] = useState(false);
|
||||
const [envExamples, setEnvExamples] = useState<EnvExample[]>([]);
|
||||
const [envLoading, setEnvLoading] = useState(false);
|
||||
const [envContent, setEnvContent] = useState("");
|
||||
const [envExampleName, setEnvExampleName] = useState("");
|
||||
const [showEnv, setShowEnv] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("details");
|
||||
const stream = useDeploymentStream(id || "");
|
||||
const socket = useSocket();
|
||||
const isEdit = useMemo(() => !!form._id, [form._id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -27,12 +86,36 @@ export function DeploymentDetailPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket || !id) return;
|
||||
socket.emit("deployment:subscribe", { deploymentId: id });
|
||||
const handleRunUpdate = ({ deploymentId, run }: { deploymentId: string; run: DeploymentRun }) => {
|
||||
if (deploymentId !== id) return;
|
||||
setRuns((prev) => {
|
||||
const existingIndex = prev.findIndex((item) => item._id === run._id);
|
||||
if (existingIndex >= 0) {
|
||||
const next = [...prev];
|
||||
next[existingIndex] = { ...next[existingIndex], ...run };
|
||||
return next;
|
||||
}
|
||||
return [run, ...prev];
|
||||
});
|
||||
};
|
||||
socket.on("deployment:run", handleRunUpdate);
|
||||
return () => {
|
||||
socket.emit("deployment:unsubscribe", { deploymentId: id });
|
||||
socket.off("deployment:run", handleRunUpdate);
|
||||
};
|
||||
}, [socket, id]);
|
||||
|
||||
const webhookUrl = useMemo(() => {
|
||||
if (!project) return "";
|
||||
return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`;
|
||||
}, [project]);
|
||||
|
||||
const latestRun = runs[0];
|
||||
const effectiveStatus = stream.status || project?.lastStatus || latestRun?.status || "idle";
|
||||
const currentLogs = stream.logs.length > 0 ? stream.logs : latestRun?.logs || [];
|
||||
|
||||
const decorateLogLine = (line: string) => {
|
||||
const lower = line.toLowerCase();
|
||||
@@ -56,7 +139,20 @@ export function DeploymentDetailPage() {
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(webhookUrl);
|
||||
} else {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = webhookUrl;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
if (!ok) throw new Error("copy failed");
|
||||
}
|
||||
toast.success("Webhook URL kopyalandı");
|
||||
} catch {
|
||||
toast.error("Webhook URL kopyalanamadı");
|
||||
@@ -76,6 +172,155 @@ export function DeploymentDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
if (!id) return;
|
||||
setRestarting(true);
|
||||
try {
|
||||
await restartDeployment(id, "restart");
|
||||
toast.success("Restart tetiklendi");
|
||||
} catch {
|
||||
toast.error("Restart tetiklenemedi");
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const repoUrl = form.repoUrl.trim();
|
||||
if (!repoUrl) {
|
||||
setBranchOptions([]);
|
||||
setComposeOptions([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
setBranchLoading(true);
|
||||
try {
|
||||
const branches = await fetchDeploymentBranches(repoUrl);
|
||||
setBranchOptions(branches);
|
||||
if (!form.branch && branches.length > 0) {
|
||||
setForm((prev) => ({ ...prev, branch: branches.includes("main") ? "main" : branches[0] }));
|
||||
}
|
||||
} catch {
|
||||
setBranchOptions([]);
|
||||
} finally {
|
||||
setBranchLoading(false);
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [form.repoUrl, form.branch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repoUrl = form.repoUrl.trim();
|
||||
const branch = form.branch.trim();
|
||||
if (!repoUrl || !branch) {
|
||||
setEnvExamples([]);
|
||||
setEnvExampleName("");
|
||||
setComposeOptions([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
setComposeLoading(true);
|
||||
try {
|
||||
const files = await fetchDeploymentComposeFiles(repoUrl, branch);
|
||||
setComposeOptions(files);
|
||||
if (files.length > 0 && !files.includes(form.composeFile)) {
|
||||
setForm((prev) => ({ ...prev, composeFile: files[0] }));
|
||||
}
|
||||
} catch {
|
||||
setComposeOptions([]);
|
||||
} finally {
|
||||
setComposeLoading(false);
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [form.repoUrl, form.branch, form.composeFile]);
|
||||
|
||||
useEffect(() => {
|
||||
const repoUrl = form.repoUrl.trim();
|
||||
const branch = form.branch.trim();
|
||||
if (!repoUrl || !branch) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
setEnvLoading(true);
|
||||
try {
|
||||
const examples = await fetchDeploymentEnvExamples(repoUrl, branch);
|
||||
setEnvExamples(examples);
|
||||
if (examples.length === 0) {
|
||||
return;
|
||||
}
|
||||
const selected = examples.find((example) => example.name === envExampleName) || examples[0];
|
||||
if (!isEdit || !envContent) {
|
||||
setEnvExampleName(selected.name);
|
||||
setEnvContent(selected.content);
|
||||
}
|
||||
} catch {
|
||||
setEnvExamples([]);
|
||||
} finally {
|
||||
setEnvLoading(false);
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [form.repoUrl, form.branch, envExampleName, isEdit, envContent]);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!project) return;
|
||||
const { _id, name, repoUrl, branch, composeFile, port } = project;
|
||||
setForm({
|
||||
_id,
|
||||
name,
|
||||
repoUrl,
|
||||
branch,
|
||||
composeFile,
|
||||
port: port ? String(port) : ""
|
||||
});
|
||||
setEnvContent(project.envContent || "");
|
||||
setEnvExampleName(project.envExampleName || "");
|
||||
setShowEnv(false);
|
||||
setActiveTab("details");
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form._id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: DeploymentInput = {
|
||||
name: form.name,
|
||||
repoUrl: form.repoUrl,
|
||||
branch: form.branch,
|
||||
composeFile: form.composeFile,
|
||||
port: form.port ? Number(form.port) : undefined,
|
||||
envContent: envContent.trim() ? envContent : undefined,
|
||||
envExampleName: envExampleName || undefined
|
||||
};
|
||||
|
||||
if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) {
|
||||
toast.error("Tüm alanları doldurun");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateDeployment(form._id, payload);
|
||||
setProject(updated);
|
||||
try {
|
||||
await runDeployment(updated._id, "update deploy");
|
||||
} catch {
|
||||
toast.error("Deploy tetiklenemedi");
|
||||
}
|
||||
toast.success("Deployment güncellendi");
|
||||
setModalOpen(false);
|
||||
} catch {
|
||||
toast.error("İşlem sırasında hata oluştu");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
||||
@@ -93,6 +338,7 @@ export function DeploymentDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -107,10 +353,14 @@ export function DeploymentDetailPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate("/deployments", { state: { editDeploymentId: project._id } })}
|
||||
onClick={handleEdit}
|
||||
>
|
||||
Düzenle
|
||||
</Button>
|
||||
<Button onClick={handleRestart} disabled={restarting} className="gap-2">
|
||||
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
|
||||
{restarting ? "Restarting..." : "Restart"}
|
||||
</Button>
|
||||
<Button onClick={handleRun} disabled={triggering} className="gap-2">
|
||||
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
|
||||
{triggering ? "Deploying..." : "Deploy"}
|
||||
@@ -121,7 +371,7 @@ export function DeploymentDetailPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Genel Bilgiler</CardTitle>
|
||||
<JobStatusBadge status={project.lastStatus} />
|
||||
<JobStatusBadge status={effectiveStatus} />
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -204,8 +454,8 @@ export function DeploymentDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-72 overflow-auto rounded-md border border-border bg-black px-3 py-2 font-mono text-xs text-green-100">
|
||||
{latestRun?.logs?.length ? (
|
||||
latestRun.logs.map((line, idx) => (
|
||||
{currentLogs.length ? (
|
||||
[...currentLogs].reverse().map((line, idx) => (
|
||||
<div key={idx} className="whitespace-pre-wrap">
|
||||
{decorateLogLine(line)}
|
||||
</div>
|
||||
@@ -217,5 +467,230 @@ export function DeploymentDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{modalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
|
||||
<div
|
||||
className="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"
|
||||
style={{ height: 620 }}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-semibold text-foreground">Deployment Güncelle</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Repo ve branch seçimi sonrası webhook tetiklemeleriyle deploy yapılır.
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={handleClose}>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden px-5 py-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">Genel</TabsTrigger>
|
||||
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="h-[420px] space-y-4">
|
||||
{!isEdit && (
|
||||
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||
Repo URL girildiğinde branch ve compose dosyaları listelenir.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repo">Repo URL</Label>
|
||||
<Input
|
||||
id="repo"
|
||||
value={form.repoUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
|
||||
placeholder="https://gitea.example.com/org/repo"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Deployment Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="wisecolt-app"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="branch">Branch</Label>
|
||||
{branchOptions.length > 0 ? (
|
||||
<Select
|
||||
value={form.branch}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, branch: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Branch seçin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{branchOptions.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>
|
||||
{branch}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id="branch"
|
||||
value={form.branch}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, branch: e.target.value }))}
|
||||
placeholder="main"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||
{branchLoading
|
||||
? "Branch listesi alınıyor..."
|
||||
: branchOptions.length > 0
|
||||
? "Repo üzerindeki branch'lar listelendi."
|
||||
: "Repo URL girildiğinde branch listesi otomatik gelir."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Compose Dosyası</Label>
|
||||
<Select
|
||||
value={form.composeFile}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
composeFile: value as DeploymentInput["composeFile"]
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Compose seçin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(composeOptions.length > 0
|
||||
? composeOptions
|
||||
: ["docker-compose.yml", "docker-compose.dev.yml"]
|
||||
).map((file) => (
|
||||
<SelectItem key={file} value={file}>
|
||||
{file}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||
{composeLoading
|
||||
? "Compose dosyaları alınıyor..."
|
||||
: composeOptions.length > 0
|
||||
? "Repo üzerindeki compose dosyaları listelendi."
|
||||
: "Repo URL ve branch sonrası compose dosyaları listelenir."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port">Port (opsiyonel)</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.port}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, port: e.target.value }))}
|
||||
placeholder="3000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="environment" className="h-[420px] space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>.env.example</Label>
|
||||
{envExamples.length > 0 ? (
|
||||
<Select
|
||||
value={envExampleName}
|
||||
onValueChange={(value) => {
|
||||
const example = envExamples.find((item) => item.name === value);
|
||||
setEnvExampleName(value);
|
||||
if (example) {
|
||||
setEnvContent(example.content);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Env example seçin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{envExamples.map((example) => (
|
||||
<SelectItem key={example.name} value={example.name}>
|
||||
{example.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="h-[2.5rem] rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
|
||||
{envLoading
|
||||
? "Env example dosyaları alınıyor..."
|
||||
: "Repo içinde .env.example bulunamadı."}
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||
{envExamples.length > 0
|
||||
? "Repo üzerindeki env example dosyaları listelendi."
|
||||
: envLoading
|
||||
? "Env example dosyaları alınıyor..."
|
||||
: "Repo içinde .env.example bulunamadı."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="env-content">Environment</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowEnv((prev) => !prev)}
|
||||
>
|
||||
<FontAwesomeIcon icon={showEnv ? faEyeSlash : faEye} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
id="env-content"
|
||||
value={envContent}
|
||||
onChange={(e) => setEnvContent(e.target.value)}
|
||||
className="h-[180px] w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm font-mono text-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
style={
|
||||
showEnv ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties)
|
||||
}
|
||||
placeholder="ENV içerikleri burada listelenir."
|
||||
/>
|
||||
<div className="min-h-[1.25rem] text-xs text-muted-foreground">
|
||||
Kaydedince içerik deployment kök dizinine{" "}
|
||||
<span className="font-mono">.env</span> olarak yazılır.
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
|
||||
<Button variant="ghost" onClick={handleClose} disabled={saving}>
|
||||
İptal
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Kaydediliyor..." : "Kaydet"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCloudArrowUp,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faPenToSquare,
|
||||
faPlus,
|
||||
faRotate,
|
||||
faRocket
|
||||
@@ -13,6 +16,7 @@ import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import {
|
||||
createDeployment,
|
||||
deleteDeployment,
|
||||
@@ -20,11 +24,14 @@ import {
|
||||
DeploymentProject,
|
||||
fetchDeploymentComposeFiles,
|
||||
fetchDeploymentBranches,
|
||||
fetchDeploymentEnvExamples,
|
||||
fetchDeployments,
|
||||
restartDeployment,
|
||||
runDeployment,
|
||||
updateDeployment
|
||||
} from "../api/deployments";
|
||||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||
import { useLiveData } from "../providers/live-provider";
|
||||
|
||||
type FormState = {
|
||||
_id?: string;
|
||||
@@ -35,6 +42,8 @@ type FormState = {
|
||||
port: string;
|
||||
};
|
||||
|
||||
type EnvExample = { name: string; content: string };
|
||||
|
||||
const defaultForm: FormState = {
|
||||
name: "",
|
||||
repoUrl: "",
|
||||
@@ -46,6 +55,7 @@ const defaultForm: FormState = {
|
||||
export function DeploymentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { deploymentStreams } = useLiveData();
|
||||
const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "");
|
||||
const [deployments, setDeployments] = useState<DeploymentProject[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -57,6 +67,12 @@ export function DeploymentsPage() {
|
||||
const [branchLoading, setBranchLoading] = useState(false);
|
||||
const [composeOptions, setComposeOptions] = useState<DeploymentInput["composeFile"][]>([]);
|
||||
const [composeLoading, setComposeLoading] = useState(false);
|
||||
const [envExamples, setEnvExamples] = useState<EnvExample[]>([]);
|
||||
const [envLoading, setEnvLoading] = useState(false);
|
||||
const [envContent, setEnvContent] = useState("");
|
||||
const [envExampleName, setEnvExampleName] = useState("");
|
||||
const [showEnv, setShowEnv] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("details");
|
||||
const [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({});
|
||||
|
||||
const isEdit = useMemo(() => !!form._id, [form._id]);
|
||||
@@ -84,6 +100,14 @@ export function DeploymentsPage() {
|
||||
setComposeOptions([]);
|
||||
return;
|
||||
}
|
||||
if (!form._id && !form.name) {
|
||||
const normalized = repoUrl.replace(/\/+$/, "");
|
||||
const lastPart = normalized.split("/").pop() || "";
|
||||
const name = lastPart.replace(/\.git$/i, "");
|
||||
if (name) {
|
||||
setForm((prev) => ({ ...prev, name }));
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
setBranchLoading(true);
|
||||
try {
|
||||
@@ -105,6 +129,11 @@ export function DeploymentsPage() {
|
||||
const repoUrl = form.repoUrl.trim();
|
||||
const branch = form.branch.trim();
|
||||
if (!repoUrl || !branch) {
|
||||
setEnvExamples([]);
|
||||
setEnvExampleName("");
|
||||
if (!isEdit) {
|
||||
setEnvContent("");
|
||||
}
|
||||
setComposeOptions([]);
|
||||
return;
|
||||
}
|
||||
@@ -125,6 +154,38 @@ export function DeploymentsPage() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [form.repoUrl, form.branch, form.composeFile]);
|
||||
|
||||
useEffect(() => {
|
||||
const repoUrl = form.repoUrl.trim();
|
||||
const branch = form.branch.trim();
|
||||
if (!repoUrl || !branch) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
setEnvLoading(true);
|
||||
try {
|
||||
const examples = await fetchDeploymentEnvExamples(repoUrl, branch);
|
||||
setEnvExamples(examples);
|
||||
if (examples.length === 0) {
|
||||
if (!isEdit) {
|
||||
setEnvExampleName("");
|
||||
setEnvContent("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const selected = examples.find((example) => example.name === envExampleName) || examples[0];
|
||||
if (!isEdit || !envContent) {
|
||||
setEnvExampleName(selected.name);
|
||||
setEnvContent(selected.content);
|
||||
}
|
||||
} catch {
|
||||
setEnvExamples([]);
|
||||
} finally {
|
||||
setEnvLoading(false);
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [form.repoUrl, form.branch, envExampleName, isEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as { editDeploymentId?: string } | null;
|
||||
if (state?.editDeploymentId) {
|
||||
@@ -146,6 +207,11 @@ export function DeploymentsPage() {
|
||||
setForm(defaultForm);
|
||||
setBranchOptions([]);
|
||||
setComposeOptions([]);
|
||||
setEnvExamples([]);
|
||||
setEnvContent("");
|
||||
setEnvExampleName("");
|
||||
setShowEnv(false);
|
||||
setActiveTab("details");
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -159,6 +225,10 @@ export function DeploymentsPage() {
|
||||
composeFile,
|
||||
port: port ? String(port) : ""
|
||||
});
|
||||
setEnvContent(deployment.envContent || "");
|
||||
setEnvExampleName(deployment.envExampleName || "");
|
||||
setShowEnv(false);
|
||||
setActiveTab("details");
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -174,7 +244,9 @@ export function DeploymentsPage() {
|
||||
repoUrl: form.repoUrl,
|
||||
branch: form.branch,
|
||||
composeFile: form.composeFile,
|
||||
port: form.port ? Number(form.port) : undefined
|
||||
port: form.port ? Number(form.port) : undefined,
|
||||
envContent: envContent.trim() ? envContent : undefined,
|
||||
envExampleName: envExampleName || undefined
|
||||
};
|
||||
|
||||
if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) {
|
||||
@@ -189,9 +261,16 @@ export function DeploymentsPage() {
|
||||
repoUrl: payload.repoUrl,
|
||||
branch: payload.branch,
|
||||
composeFile: payload.composeFile,
|
||||
port: payload.port
|
||||
port: payload.port,
|
||||
envContent: payload.envContent,
|
||||
envExampleName: payload.envExampleName
|
||||
});
|
||||
setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d)));
|
||||
try {
|
||||
await runDeployment(updated._id, "update deploy");
|
||||
} catch {
|
||||
toast.error("Deploy tetiklenemedi");
|
||||
}
|
||||
toast.success("Deployment güncellendi");
|
||||
} else {
|
||||
const created = await createDeployment(payload);
|
||||
@@ -216,6 +295,15 @@ export function DeploymentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async (id: string) => {
|
||||
try {
|
||||
await restartDeployment(id, "restart");
|
||||
toast.success("Restart tetiklendi");
|
||||
} catch {
|
||||
toast.error("Restart tetiklenemedi");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (deployment: DeploymentProject) => {
|
||||
const ok = window.confirm("Bu deployment'ı silmek istediğinize emin misiniz?");
|
||||
if (!ok) return;
|
||||
@@ -292,7 +380,9 @@ export function DeploymentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<JobStatusBadge status={deployment.lastStatus} />
|
||||
<JobStatusBadge
|
||||
status={deploymentStreams[deployment._id]?.status || deployment.lastStatus}
|
||||
/>
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
|
||||
{deployment.env.toUpperCase()}
|
||||
</span>
|
||||
@@ -314,6 +404,18 @@ export function DeploymentsPage() {
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRestart(deployment._id);
|
||||
}}
|
||||
title="Restart"
|
||||
aria-label="Restart"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -323,7 +425,7 @@ export function DeploymentsPage() {
|
||||
}}
|
||||
title="Düzenle"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
|
||||
<FontAwesomeIcon icon={faPenToSquare} className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -361,7 +463,10 @@ export function DeploymentsPage() {
|
||||
|
||||
{modalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
|
||||
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-border bg-card card-shadow">
|
||||
<div
|
||||
className="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"
|
||||
style={{ height: 626 }}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-semibold text-foreground">
|
||||
@@ -376,9 +481,16 @@ export function DeploymentsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4">
|
||||
<div className="flex-1 overflow-hidden px-5 py-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">Genel</TabsTrigger>
|
||||
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="h-[420px] space-y-4">
|
||||
{!isEdit && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||
Repo URL girildiğinde branch ve compose dosyaları listelenir.
|
||||
</div>
|
||||
)}
|
||||
@@ -432,7 +544,7 @@ export function DeploymentsPage() {
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||
{branchLoading
|
||||
? "Branch listesi alınıyor..."
|
||||
: branchOptions.length > 0
|
||||
@@ -448,7 +560,10 @@ export function DeploymentsPage() {
|
||||
<Select
|
||||
value={form.composeFile}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, composeFile: value as DeploymentInput["composeFile"] }))
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
composeFile: value as DeploymentInput["composeFile"]
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -465,7 +580,7 @@ export function DeploymentsPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||
{composeLoading
|
||||
? "Compose dosyaları alınıyor..."
|
||||
: composeOptions.length > 0
|
||||
@@ -486,6 +601,77 @@ export function DeploymentsPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="environment" className="h-[420px] space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>.env.example</Label>
|
||||
{envExamples.length > 0 ? (
|
||||
<Select
|
||||
value={envExampleName}
|
||||
onValueChange={(value) => {
|
||||
const example = envExamples.find((item) => item.name === value);
|
||||
setEnvExampleName(value);
|
||||
if (example) {
|
||||
setEnvContent(example.content);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Env example seçin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{envExamples.map((example) => (
|
||||
<SelectItem key={example.name} value={example.name}>
|
||||
{example.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="h-[2.5rem] rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
|
||||
{envLoading
|
||||
? "Env example dosyaları alınıyor..."
|
||||
: "Repo içinde .env.example bulunamadı."}
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||
{envExamples.length > 0
|
||||
? "Repo üzerindeki env example dosyaları listelendi."
|
||||
: envLoading
|
||||
? "Env example dosyaları alınıyor..."
|
||||
: "Repo içinde .env.example bulunamadı."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="env-content">Environment</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowEnv((prev) => !prev)}
|
||||
>
|
||||
<FontAwesomeIcon icon={showEnv ? faEyeSlash : faEye} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
id="env-content"
|
||||
value={envContent}
|
||||
onChange={(e) => setEnvContent(e.target.value)}
|
||||
className="h-[180px] w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm font-mono text-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
style={
|
||||
showEnv ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties)
|
||||
}
|
||||
placeholder="ENV içerikleri burada listelenir."
|
||||
/>
|
||||
<div className="min-h-[1.25rem] text-xs text-muted-foreground">
|
||||
Kaydedince içerik deployment kök dizinine <span className="font-mono">.env</span> olarak yazılır.
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Line,
|
||||
LineChart,
|
||||
@@ -18,6 +18,7 @@ import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||
import { RepoIcon } from "../components/RepoIcon";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faClockRotateLeft, faListCheck, faFlaskVial, faRocket } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useAuth } from "../providers/auth-provider";
|
||||
|
||||
function formatDuration(ms?: number) {
|
||||
if (!ms || Number.isNaN(ms)) return "-";
|
||||
@@ -41,9 +42,14 @@ export function HomePage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { jobStreams } = useLiveData();
|
||||
const { token } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const loadMetrics = useCallback(() => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()])
|
||||
.then(([jobResult, deployResult]) => {
|
||||
if (jobResult.status === "fulfilled") {
|
||||
@@ -65,7 +71,25 @@ export function HomePage() {
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMetrics();
|
||||
}, [loadMetrics, location.key]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
loadMetrics();
|
||||
}
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
document.addEventListener("visibilitychange", handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
document.removeEventListener("visibilitychange", handleFocus);
|
||||
};
|
||||
}, [loadMetrics]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!metrics) {
|
||||
@@ -273,6 +297,7 @@ export function HomePage() {
|
||||
<RepoIcon repoUrl={run.repoUrl} />
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<span>{run.title}</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${
|
||||
run.type === "test"
|
||||
@@ -286,7 +311,6 @@ export function HomePage() {
|
||||
/>
|
||||
{run.type === "test" ? "Test" : "Deploy"}
|
||||
</span>
|
||||
<span>{run.title}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(run.startedAt).toLocaleString()} · Süre:{" "}
|
||||
|
||||
@@ -23,7 +23,20 @@ export function SettingsPage() {
|
||||
|
||||
const handleCopy = async (value: string, label: string) => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} else {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = value;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
if (!ok) throw new Error("copy failed");
|
||||
}
|
||||
toast.success(`${label} kopyalandı`);
|
||||
} catch {
|
||||
toast.error(`${label} kopyalanamadı`);
|
||||
|
||||
@@ -23,7 +23,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
setToken(stored);
|
||||
fetchMe()
|
||||
.then((data) => setUser({ username: data.username }))
|
||||
.catch(() => setAuthToken(undefined))
|
||||
.catch(() => {
|
||||
setAuthToken(undefined);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
setLoading(false);
|
||||
|
||||
@@ -12,6 +12,7 @@ type JobStream = {
|
||||
|
||||
type LiveContextValue = {
|
||||
jobStreams: Record<string, JobStream>;
|
||||
deploymentStreams: Record<string, JobStream>;
|
||||
};
|
||||
|
||||
const LiveContext = createContext<LiveContextValue | undefined>(undefined);
|
||||
@@ -19,6 +20,7 @@ const LiveContext = createContext<LiveContextValue | undefined>(undefined);
|
||||
export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const socket = useSocket();
|
||||
const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({});
|
||||
const [deploymentStreams, setDeploymentStreams] = useState<Record<string, JobStream>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
@@ -54,20 +56,59 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeploymentLog = ({ deploymentId, line }: { deploymentId: string; line: string }) => {
|
||||
if (!deploymentId) return;
|
||||
setDeploymentStreams((prev) => {
|
||||
const current = prev[deploymentId] || { logs: [] };
|
||||
const nextLogs = [...current.logs, line].slice(-200);
|
||||
return { ...prev, [deploymentId]: { ...current, logs: nextLogs } };
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeploymentStatus = ({
|
||||
deploymentId,
|
||||
status,
|
||||
lastRunAt,
|
||||
lastMessage,
|
||||
runCount,
|
||||
lastDurationMs
|
||||
}: {
|
||||
deploymentId: string;
|
||||
status?: string;
|
||||
lastRunAt?: string;
|
||||
lastMessage?: string;
|
||||
runCount?: number;
|
||||
lastDurationMs?: number;
|
||||
}) => {
|
||||
if (!deploymentId) return;
|
||||
setDeploymentStreams((prev) => {
|
||||
const current = prev[deploymentId] || { logs: [] };
|
||||
return {
|
||||
...prev,
|
||||
[deploymentId]: { ...current, status, lastRunAt, lastMessage, runCount, lastDurationMs }
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
socket.on("job:log", handleJobLog);
|
||||
socket.on("job:status", handleJobStatus);
|
||||
socket.on("deployment:log", handleDeploymentLog);
|
||||
socket.on("deployment:status", handleDeploymentStatus);
|
||||
|
||||
return () => {
|
||||
socket.off("job:log", handleJobLog);
|
||||
socket.off("job:status", handleJobStatus);
|
||||
socket.off("deployment:log", handleDeploymentLog);
|
||||
socket.off("deployment:status", handleDeploymentStatus);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
jobStreams
|
||||
jobStreams,
|
||||
deploymentStreams
|
||||
}),
|
||||
[jobStreams]
|
||||
[jobStreams, deploymentStreams]
|
||||
);
|
||||
|
||||
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
|
||||
@@ -87,3 +128,12 @@ export function useJobStream(jobId: string) {
|
||||
[ctx.jobStreams, jobId]
|
||||
);
|
||||
}
|
||||
|
||||
export function useDeploymentStream(deploymentId: string) {
|
||||
const ctx = useContext(LiveContext);
|
||||
if (!ctx) throw new Error("useDeploymentStream LiveProvider içinde kullanılmalı");
|
||||
return useMemo(
|
||||
() => ctx.deploymentStreams[deploymentId] || { logs: [], status: "idle", runCount: 0 },
|
||||
[ctx.deploymentStreams, deploymentId]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const baseUrl = useMemo(() => apiClient.defaults.baseURL || window.location.origin, []);
|
||||
const baseUrl = useMemo(() => window.location.origin, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -22,6 +22,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
|
||||
const socket = io(baseUrl, {
|
||||
auth: { token },
|
||||
path: "/api/socket.io",
|
||||
transports: ["websocket", "polling"]
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user