Compare commits

..

1 Commits

Author SHA1 Message Date
2b053120cb Projeleri otomatik deployment etme özelliği eklendi.
Reviewed-on: #1
2026-01-18 13:40:52 +00:00
12 changed files with 213 additions and 203 deletions

View File

@@ -21,7 +21,7 @@
- **Korumalı Endpoint'ler**: JWT middleware ile korunan API endpoint'leri - **Korumalı Endpoint'ler**: JWT middleware ile korunan API endpoint'leri
- **Environment Security**: Hassas bilgilerin güvenli .env dosyasında saklanması - **Environment Security**: Hassas bilgilerin güvenli .env dosyasında saklanması
### 🧪 Test Yönetim Sistemi ### 📊 Job Yönetim Sistemi
- **Repository Otomasyonu**: Otomatik git clone/pull işlemleri - **Repository Otomasyonu**: Otomatik git clone/pull işlemleri
- **Zaman Tabanlı Çalıştırma**: Dakika/saat/gün bazında otomatik test çalıştırma - **Zaman Tabanlı Çalıştırma**: Dakika/saat/gün bazında otomatik test çalıştırma
- **Real-time Durum Güncellemesi**: Socket.io ile anlık durum takibi - **Real-time Durum Güncellemesi**: Socket.io ile anlık durum takibi
@@ -29,7 +29,7 @@
- **Log Akışı**: Gerçek zamanlı test loglarının izlenmesi - **Log Akışı**: Gerçek zamanlı test loglarının izlenmesi
### 🚀 Deployment Yönetimi ### 🚀 Deployment Yönetimi
- **Repo Bazlı Kurulum**: Repo URL ile proje oluşturma ve deploy klasörünü otomatik oluşturma - **Root Tarama**: `DEPLOYMENTS_ROOT_HOST` altında compose dosyası olan projeleri otomatik bulma
- **Webhook Tetikleme**: Gitea push event ile otomatik deploy - **Webhook Tetikleme**: Gitea push event ile otomatik deploy
- **Branch Seçimi**: Repo URL girince branch listesi alınır ve seçim yapılır - **Branch Seçimi**: Repo URL girince branch listesi alınır ve seçim yapılır
- **Deploy Geçmişi**: Her deploy için log ve süre kaydı - **Deploy Geçmişi**: Her deploy için log ve süre kaydı
@@ -38,7 +38,7 @@
### ⚡ Gerçek Zamanlı İletişim ### ⚡ Gerçek Zamanlı İletişim
- **WebSocket Bağlantısı**: Socket.io ile sürekli iletişim - **WebSocket Bağlantısı**: Socket.io ile sürekli iletişim
- **Sayaç Yayınınlaması**: Global sayaç ve işlemler - **Sayaç Yayınınlaması**: Global sayaç ve işlemler
- **Canlı Güncellemeler**: Test durumlarının anlık bildirilmesi - **Canlı Güncellemeler**: Job durumlarının anlık bildirilmesi
- **Ping/Pong**: Bağlantı kontrolü - **Ping/Pong**: Bağlantı kontrolü
### 🎨 Modern Arayüz ### 🎨 Modern Arayüz
@@ -202,29 +202,29 @@ docker compose up -d --build
- **Şifre**: `supersecret` - **Şifre**: `supersecret`
3. Giriş yap butonuna tıklayın 3. Giriş yap butonuna tıklayın
### Test Yönetimi ### Job Yönetimi
#### Yeni Test Oluşturma #### Yeni Job Oluşturma
1. **Dashboard** menüsünden **Tests** sayfasına gidin 1. **Dashboard** menüsünden **Jobs** sayfasına gidin
2. **Yeni Test** butonuna tıklayın 2. **Yeni Job** butonuna tıklayın
3. Test bilgilerini girin: 3. Job bilgilerini girin:
- **Test Adı**: Tanımlayıcı bir isim - **Job Adı**: Tanımlayıcı bir isim
- **Repository URL**: GitHub repository adresi - **Repository URL**: GitHub repository adresi
- **Test Komutu**: Çalıştırılacak komut (örn: `npm test`) - **Test Komutu**: Çalıştırılacak komut (örn: `npm test`)
- **Kontrol Aralığı**: Test sıklığı (dakika/saat/gün) - **Kontrol Aralığı**: Test sıklığı (dakika/saat/gün)
- **Kontrol Değeri**: Sayısal değer - **Kontrol Değeri**: Sayısal değer
4. Kaydet butonuna tıklayın 4. Kaydet butonuna tıklayın
#### Test İzleme #### Job İzleme
- **Tests Listesi**: Tüm test'lerin durumunu gösterir - **Jobs Listesi**: Tüm job'ların durumunu gösterir
- **Real-time Durum**: Socket.io ile anlık güncellemeler - **Real-time Durum**: Socket.io ile anlık güncellemeler
- **Log Akışı**: Test çıktılarını canlı izleme - **Log Akışı**: Test çıktılarını canlı izleme
- **Manuel Çalıştırma**: Test'i anında tetikleme - **Manuel Çalıştırma**: Job'u anında tetikleme
### Deployment Yönetimi ### Deployment Yönetimi
1. **Deployments** sayfasına gidin 1. **Deployments** sayfasına gidin
2. **New Deployment** ile Repo URL girin 2. **New Deployment** ile root altında taranan projeyi seçin
3. Branch ve Compose dosyasını seçin 3. Repo URL + Branch + Compose dosyasını girin
4. Kaydettikten sonra **Webhook URL**i Giteada web istemci olarak tanımlayın 4. Kaydettikten sonra **Webhook URL**i Giteada web istemci olarak tanımlayın
#### Webhook Ayarları (Gitea) #### Webhook Ayarları (Gitea)
@@ -250,8 +250,8 @@ docker compose up -d --build
### 📖 API Referansı ### 📖 API Referansı
- **Authentication API'leri**: `/auth/login`, `/auth/me` - **Authentication API'leri**: `/auth/login`, `/auth/me`
- **Test Yönetim API'leri**: CRUD operasyonları, manuel çalıştırma - **Job Yönetim API'leri**: CRUD operasyonları, manuel çalıştırma
- **Deployment API'leri**: `/deployments`, `/deployments/:id`, `/deployments/branches`, `/deployments/compose-files` - **Deployment API'leri**: `/deployments`, `/deployments/:id`, `/deployments/scan`, `/deployments/branches`
- **Webhook Endpoint**: `/api/deployments/webhook/:token` - **Webhook Endpoint**: `/api/deployments/webhook/:token`
- **WebSocket Olayları**: Real-time iletişim ve durum güncellemeleri - **WebSocket Olayları**: Real-time iletişim ve durum güncellemeleri
- **Endpoint Detayları**: Her endpoint için istek/yanıt formatları - **Endpoint Detayları**: Her endpoint için istek/yanıt formatları
@@ -393,12 +393,12 @@ docker compose logs mongo
### Mevcut Durum (v1.0) ### Mevcut Durum (v1.0)
- ✅ Temel CI/CD platformu - ✅ Temel CI/CD platformu
- ✅ Real-time test yönetimi - ✅ Real-time job yönetimi
- ✅ Modern web arayüzü - ✅ Modern web arayüzü
- ✅ Konteyner orkestrasyonu - ✅ Konteyner orkestrasyonu
### Gelecek Planlar ### Gelecek Planlar
- 🔄 **Multi-branch Support**: Farklı branch'ler için test yönetimi - 🔄 **Multi-branch Support**: Farklı branch'ler için job yönetimi
- 🔔 **Bildirim Sistemi**: E-posta ve Slack bildirimleri - 🔔 **Bildirim Sistemi**: E-posta ve Slack bildirimleri
- 📊 **Dashboard İstatistikleri**: Performans ve kullanım metrikleri - 📊 **Dashboard İstatistikleri**: Performans ve kullanım metrikleri
- 🛡️ **Güvenlik İyileştirmeleri**: 2FA ve rate limiting - 🛡️ **Güvenlik İyileştirmeleri**: 2FA ve rate limiting
@@ -406,7 +406,7 @@ docker compose logs mongo
- 📝 **Custom Test Commands**: Esnek test komutu yapılandırması - 📝 **Custom Test Commands**: Esnek test komutu yapılandırması
### E-post Listesi ### E-post Listesi
- 📊 **Dashboard İstatistikleri**: Test performans grafikleri - 📊 **Dashboard İstatistikleri**: Job performans grafikleri
- 🔔 **Bildirim Kanalları**: Slack, Discord, Teams entegrasyonu - 🔔 **Bildirim Kanalları**: Slack, Discord, Teams entegrasyonu
- 🔄 **Pipeline Integration**: GitHub Actions, GitLab CI entegrasyonu - 🔄 **Pipeline Integration**: GitHub Actions, GitLab CI entegrasyonu
- 🏗️ **Template System**: Hazır proje şablonları - 🏗️ **Template System**: Hazır proje şablonları

View File

@@ -1,7 +1,7 @@
PORT=4000 PORT=4000
# Prod için zorunlu Mongo bağlantısı # Prod için zorunlu Mongo bağlantısı
# Örnek: mongodb://mongo:27017/wisecoltci # Örnek: mongodb://<APP_USER>:<APP_PASS>@<HOST>:27017/wisecoltci?authSource=wisecoltci
MONGO_URI=mongodb://mongo:27017/wisecoltci MONGO_URI=mongodb://app:change-me@mongo-host:27017/wisecoltci?authSource=wisecoltci
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=supersecret ADMIN_PASSWORD=supersecret
JWT_SECRET=change-me JWT_SECRET=change-me

View File

@@ -8,7 +8,8 @@ export const config = {
adminUsername: process.env.ADMIN_USERNAME || "admin", adminUsername: process.env.ADMIN_USERNAME || "admin",
adminPassword: process.env.ADMIN_PASSWORD || "password", adminPassword: process.env.ADMIN_PASSWORD || "password",
jwtSecret: process.env.JWT_SECRET || "changeme", jwtSecret: process.env.JWT_SECRET || "changeme",
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173" clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173",
deploymentsRoot: "/workspace"
}; };
if (!config.jwtSecret) { if (!config.jwtSecret) {

View File

@@ -24,7 +24,7 @@ const DeploymentProjectSchema = new Schema<DeploymentProjectDocument>(
{ {
name: { type: String, required: true, trim: true }, name: { type: String, required: true, trim: true },
rootPath: { type: String, required: true, trim: true }, rootPath: { type: String, required: true, trim: true },
repoUrl: { type: String, required: true, trim: true, unique: true, index: true }, repoUrl: { type: String, required: true, trim: true },
branch: { type: String, required: true, trim: true }, branch: { type: String, required: true, trim: true },
composeFile: { composeFile: {
type: String, type: String,

View File

@@ -39,6 +39,17 @@ router.get("/:id/favicon", async (req, res) => {
return res.status(404).end(); return res.status(404).end();
}); });
router.get("/scan", async (req, res) => {
authMiddleware(req, res, async () => {
try {
const candidates = await deploymentService.scanRoot();
return res.json(candidates);
} catch (err) {
return res.status(500).json({ message: "Root taraması yapılamadı" });
}
});
});
router.get("/branches", async (req, res) => { router.get("/branches", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const repoUrl = req.query.repoUrl as string | undefined; const repoUrl = req.query.repoUrl as string | undefined;
@@ -54,22 +65,6 @@ router.get("/branches", async (req, res) => {
}); });
}); });
router.get("/compose-files", async (req, res) => {
authMiddleware(req, res, async () => {
const repoUrl = req.query.repoUrl as string | undefined;
const branch = req.query.branch as string | undefined;
if (!repoUrl || !branch) {
return res.status(400).json({ message: "repoUrl ve branch gerekli" });
}
try {
const files = await deploymentService.listRemoteComposeFiles(repoUrl, branch);
return res.json({ files });
} catch (err) {
return res.status(400).json({ message: "Compose listesi alınamadı", error: (err as Error).message });
}
});
});
router.get("/metrics/summary", async (req, res) => { router.get("/metrics/summary", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const since = new Date(); const since = new Date();
@@ -128,13 +123,14 @@ router.get("/:id", async (req, res) => {
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const { name, repoUrl, branch, composeFile, port } = req.body; const { name, rootPath, repoUrl, branch, composeFile, port } = req.body;
if (!name || !repoUrl || !branch || !composeFile) { if (!name || !rootPath || !repoUrl || !branch || !composeFile) {
return res.status(400).json({ message: "Tüm alanlar gerekli" }); return res.status(400).json({ message: "Tüm alanlar gerekli" });
} }
try { try {
const created = await deploymentService.createProject({ const created = await deploymentService.createProject({
name, name,
rootPath,
repoUrl, repoUrl,
branch, branch,
composeFile, composeFile,

View File

@@ -2,6 +2,7 @@ import fs from "fs";
import path from "path"; import path from "path";
import crypto from "crypto"; import crypto from "crypto";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { config } from "../config/env.js";
import { import {
DeploymentProject, DeploymentProject,
DeploymentProjectDocument, DeploymentProjectDocument,
@@ -13,18 +14,14 @@ import { Settings } from "../models/settings.js";
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"]; const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
const deploymentsRoot = "/workspace/deployments"; function normalizeRoot(rootPath: string) {
return path.resolve(rootPath);
function slugify(value: string) {
return value
.toLowerCase()
.replace(/\.git$/i, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
} }
function normalizeRepoUrl(value: string) { function isWithinRoot(rootPath: string, targetPath: string) {
return value.trim().replace(/\/+$/, "").replace(/\.git$/i, ""); const resolvedRoot = normalizeRoot(rootPath);
const resolvedTarget = path.resolve(targetPath);
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`);
} }
function generateWebhookToken() { function generateWebhookToken() {
@@ -135,7 +132,10 @@ async function ensureRepo(project: DeploymentProjectDocument, onData: (line: str
} }
} }
async function runCompose(project: DeploymentProjectDocument, onData: (line: string) => void) { async function runCompose(
project: DeploymentProjectDocument,
onData: (line: string) => void
) {
const composePath = path.join(project.rootPath, project.composeFile); const composePath = path.join(project.rootPath, project.composeFile);
if (!fs.existsSync(composePath)) { if (!fs.existsSync(composePath)) {
throw new Error("Compose dosyası bulunamadı"); throw new Error("Compose dosyası bulunamadı");
@@ -153,6 +153,31 @@ async function runCompose(project: DeploymentProjectDocument, onData: (line: str
class DeploymentService { class DeploymentService {
private running: Map<string, boolean> = new Map(); private running: Map<string, boolean> = new Map();
async scanRoot() {
const rootPath = normalizeRoot(config.deploymentsRoot);
if (!fs.existsSync(rootPath)) {
throw new Error("Deployments root bulunamadı");
}
const entries = await fs.promises.readdir(rootPath, { withFileTypes: true });
const candidates = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const folderPath = path.join(rootPath, entry.name);
const available = composeFileCandidates.filter((file) =>
fs.existsSync(path.join(folderPath, file))
);
if (available.length === 0) continue;
candidates.push({
name: entry.name,
rootPath: folderPath,
composeFiles: available
});
}
return candidates;
}
async listRemoteBranches(repoUrl: string) { async listRemoteBranches(repoUrl: string) {
const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd()); const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd());
const branches = output const branches = output
@@ -165,24 +190,6 @@ class DeploymentService {
return branches; return branches;
} }
async listRemoteComposeFiles(repoUrl: string, branch: string) {
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
const tmpBase = await fs.promises.mkdtemp(path.join(deploymentsRoot, ".tmp-"));
try {
await runCommand(
`git clone --depth 1 --single-branch --branch ${branch} ${repoUrl} ${tmpBase}`,
process.cwd(),
() => undefined
);
const available = composeFileCandidates.filter((file) =>
fs.existsSync(path.join(tmpBase, file))
);
return available;
} finally {
await fs.promises.rm(tmpBase, { recursive: true, force: true });
}
}
async ensureSettings() { async ensureSettings() {
const existing = await Settings.findOne(); const existing = await Settings.findOne();
if (existing) return existing; if (existing) return existing;
@@ -209,15 +216,27 @@ class DeploymentService {
async createProject(input: { async createProject(input: {
name: string; name: string;
rootPath: string;
repoUrl: string; repoUrl: string;
branch: string; branch: string;
composeFile: ComposeFile; composeFile: ComposeFile;
port?: number; port?: number;
}) { }) {
const repoUrl = normalizeRepoUrl(input.repoUrl); const rootPath = path.resolve(input.rootPath);
const existingRepo = await DeploymentProject.findOne({ repoUrl }); if (!isWithinRoot(config.deploymentsRoot, rootPath)) {
if (existingRepo) { throw new Error("Root path deployments root dışında");
throw new Error("Bu repo zaten eklenmiş"); }
if (!fs.existsSync(rootPath)) {
throw new Error("Root path bulunamadı");
}
const composePath = path.join(rootPath, input.composeFile);
if (!fs.existsSync(composePath)) {
throw new Error("Compose dosyası bulunamadı");
}
const existing = await DeploymentProject.findOne({ rootPath });
if (existing) {
throw new Error("Bu klasör zaten eklenmiş");
} }
let webhookToken = generateWebhookToken(); let webhookToken = generateWebhookToken();
@@ -225,23 +244,11 @@ class DeploymentService {
webhookToken = generateWebhookToken(); webhookToken = generateWebhookToken();
} }
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
const baseName = slugify(path.basename(repoUrl));
const suffix = crypto.randomBytes(3).toString("hex");
const slug = baseName ? `${baseName}-${suffix}` : `deployment-${suffix}`;
const rootPath = path.join(deploymentsRoot, slug);
await fs.promises.mkdir(rootPath, { recursive: true });
const available = await this.listRemoteComposeFiles(repoUrl, input.branch);
if (!available.includes(input.composeFile)) {
throw new Error("Compose dosyası repoda bulunamadı");
}
const env = deriveEnv(input.composeFile); const env = deriveEnv(input.composeFile);
return DeploymentProject.create({ return DeploymentProject.create({
name: input.name, name: input.name,
rootPath, rootPath,
repoUrl, repoUrl: input.repoUrl,
branch: input.branch, branch: input.branch,
composeFile: input.composeFile, composeFile: input.composeFile,
webhookToken, webhookToken,
@@ -262,23 +269,16 @@ class DeploymentService {
) { ) {
const project = await DeploymentProject.findById(id); const project = await DeploymentProject.findById(id);
if (!project) return null; if (!project) return null;
const repoUrl = normalizeRepoUrl(input.repoUrl); const composePath = path.join(project.rootPath, input.composeFile);
if (repoUrl !== project.repoUrl) { if (!fs.existsSync(composePath)) {
const existingRepo = await DeploymentProject.findOne({ repoUrl }); throw new Error("Compose dosyası bulunamadı");
if (existingRepo && existingRepo._id.toString() !== id) {
throw new Error("Bu repo zaten eklenmiş");
}
}
const available = await this.listRemoteComposeFiles(repoUrl, input.branch);
if (!available.includes(input.composeFile)) {
throw new Error("Compose dosyası repoda bulunamadı");
} }
const env = deriveEnv(input.composeFile); const env = deriveEnv(input.composeFile);
const updated = await DeploymentProject.findByIdAndUpdate( const updated = await DeploymentProject.findByIdAndUpdate(
id, id,
{ {
name: input.name, name: input.name,
repoUrl, repoUrl: input.repoUrl,
branch: input.branch, branch: input.branch,
composeFile: input.composeFile, composeFile: input.composeFile,
env, env,

View File

@@ -54,8 +54,15 @@ export interface DeploymentMetrics {
recentRuns: DeploymentRunWithProject[]; recentRuns: DeploymentRunWithProject[];
} }
export interface DeploymentCandidate {
name: string;
rootPath: string;
composeFiles: ComposeFile[];
}
export interface DeploymentInput { export interface DeploymentInput {
name: string; name: string;
rootPath: string;
repoUrl: string; repoUrl: string;
branch: string; branch: string;
composeFile: ComposeFile; composeFile: ComposeFile;
@@ -74,12 +81,17 @@ export async function fetchDeploymentBranches(repoUrl: string): Promise<string[]
return (data as { branches: string[] }).branches; return (data as { branches: string[] }).branches;
} }
export async function scanDeployments(): Promise<DeploymentCandidate[]> {
const { data } = await apiClient.get("/deployments/scan");
return data as DeploymentCandidate[];
}
export async function createDeployment(payload: DeploymentInput): Promise<DeploymentProject> { export async function createDeployment(payload: DeploymentInput): Promise<DeploymentProject> {
const { data } = await apiClient.post("/deployments", payload); const { data } = await apiClient.post("/deployments", payload);
return data as DeploymentProject; return data as DeploymentProject;
} }
export async function updateDeployment(id: string, payload: DeploymentInput) { export async function updateDeployment(id: string, payload: Omit<DeploymentInput, "rootPath">) {
const { data } = await apiClient.put(`/deployments/${id}`, payload); const { data } = await apiClient.put(`/deployments/${id}`, payload);
return data as DeploymentProject; return data as DeploymentProject;
} }
@@ -101,13 +113,3 @@ export async function fetchDeploymentMetrics(): Promise<DeploymentMetrics> {
const { data } = await apiClient.get("/deployments/metrics/summary"); const { data } = await apiClient.get("/deployments/metrics/summary");
return data as DeploymentMetrics; return data as DeploymentMetrics;
} }
export async function fetchDeploymentComposeFiles(
repoUrl: string,
branch: string
): Promise<ComposeFile[]> {
const { data } = await apiClient.get("/deployments/compose-files", {
params: { repoUrl, branch }
});
return (data as { files: ComposeFile[] }).files;
}

View File

@@ -22,7 +22,7 @@ export function DashboardLayout() {
const navigation = useMemo( const navigation = useMemo(
() => [ () => [
{ label: "Home", to: "/home", icon: faHouse }, { label: "Home", to: "/home", icon: faHouse },
{ label: "Tests", to: "/jobs", icon: faFlaskVial }, { label: "Jobs", to: "/jobs", icon: faFlaskVial },
{ label: "Deployments", to: "/deployments", icon: faRocket }, { label: "Deployments", to: "/deployments", icon: faRocket },
{ label: "Settings", to: "/settings", icon: faGear } { label: "Settings", to: "/settings", icon: faGear }
], ],

View File

@@ -16,12 +16,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { import {
createDeployment, createDeployment,
deleteDeployment, deleteDeployment,
DeploymentCandidate,
DeploymentInput, DeploymentInput,
DeploymentProject, DeploymentProject,
fetchDeploymentComposeFiles,
fetchDeploymentBranches, fetchDeploymentBranches,
fetchDeployments, fetchDeployments,
runDeployment, runDeployment,
scanDeployments,
updateDeployment updateDeployment
} from "../api/deployments"; } from "../api/deployments";
import { JobStatusBadge } from "../components/JobStatusBadge"; import { JobStatusBadge } from "../components/JobStatusBadge";
@@ -29,6 +30,7 @@ import { JobStatusBadge } from "../components/JobStatusBadge";
type FormState = { type FormState = {
_id?: string; _id?: string;
name: string; name: string;
rootPath: string;
repoUrl: string; repoUrl: string;
branch: string; branch: string;
composeFile: DeploymentInput["composeFile"]; composeFile: DeploymentInput["composeFile"];
@@ -37,6 +39,7 @@ type FormState = {
const defaultForm: FormState = { const defaultForm: FormState = {
name: "", name: "",
rootPath: "",
repoUrl: "", repoUrl: "",
branch: "main", branch: "main",
composeFile: "docker-compose.yml", composeFile: "docker-compose.yml",
@@ -51,15 +54,19 @@ export function DeploymentsPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [scanning, setScanning] = useState(false);
const [candidates, setCandidates] = useState<DeploymentCandidate[]>([]);
const [form, setForm] = useState<FormState>(defaultForm); const [form, setForm] = useState<FormState>(defaultForm);
const [pendingEditId, setPendingEditId] = useState<string | null>(null); const [pendingEditId, setPendingEditId] = useState<string | null>(null);
const [branchOptions, setBranchOptions] = useState<string[]>([]); const [branchOptions, setBranchOptions] = useState<string[]>([]);
const [branchLoading, setBranchLoading] = useState(false); const [branchLoading, setBranchLoading] = useState(false);
const [composeOptions, setComposeOptions] = useState<DeploymentInput["composeFile"][]>([]);
const [composeLoading, setComposeLoading] = useState(false);
const [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({}); const [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({});
const isEdit = useMemo(() => !!form._id, [form._id]); const isEdit = useMemo(() => !!form._id, [form._id]);
const selectedCandidate = useMemo(
() => candidates.find((c) => c.rootPath === form.rootPath),
[candidates, form.rootPath]
);
const loadDeployments = async () => { const loadDeployments = async () => {
setLoading(true); setLoading(true);
@@ -73,6 +80,18 @@ export function DeploymentsPage() {
} }
}; };
const loadCandidates = async () => {
setScanning(true);
try {
const data = await scanDeployments();
setCandidates(data);
} catch {
toast.error("Root taraması yapılamadı");
} finally {
setScanning(false);
}
};
useEffect(() => { useEffect(() => {
loadDeployments(); loadDeployments();
}, []); }, []);
@@ -81,7 +100,6 @@ export function DeploymentsPage() {
const repoUrl = form.repoUrl.trim(); const repoUrl = form.repoUrl.trim();
if (!repoUrl) { if (!repoUrl) {
setBranchOptions([]); setBranchOptions([]);
setComposeOptions([]);
return; return;
} }
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
@@ -101,30 +119,6 @@ export function DeploymentsPage() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [form.repoUrl, form.branch]); }, [form.repoUrl, form.branch]);
useEffect(() => {
const repoUrl = form.repoUrl.trim();
const branch = form.branch.trim();
if (!repoUrl || !branch) {
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(() => { useEffect(() => {
const state = location.state as { editDeploymentId?: string } | null; const state = location.state as { editDeploymentId?: string } | null;
if (state?.editDeploymentId) { if (state?.editDeploymentId) {
@@ -145,15 +139,16 @@ export function DeploymentsPage() {
const handleOpenNew = async () => { const handleOpenNew = async () => {
setForm(defaultForm); setForm(defaultForm);
setBranchOptions([]); setBranchOptions([]);
setComposeOptions([]);
setModalOpen(true); setModalOpen(true);
await loadCandidates();
}; };
const handleEdit = (deployment: DeploymentProject) => { const handleEdit = (deployment: DeploymentProject) => {
const { _id, name, repoUrl, branch, composeFile, port } = deployment; const { _id, name, rootPath, repoUrl, branch, composeFile, port } = deployment;
setForm({ setForm({
_id, _id,
name, name,
rootPath,
repoUrl, repoUrl,
branch, branch,
composeFile, composeFile,
@@ -171,13 +166,14 @@ export function DeploymentsPage() {
try { try {
const payload: DeploymentInput = { const payload: DeploymentInput = {
name: form.name, name: form.name,
rootPath: form.rootPath,
repoUrl: form.repoUrl, repoUrl: form.repoUrl,
branch: form.branch, branch: form.branch,
composeFile: form.composeFile, composeFile: form.composeFile,
port: form.port ? Number(form.port) : undefined port: form.port ? Number(form.port) : undefined
}; };
if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) { if (!payload.name || !payload.rootPath || !payload.repoUrl || !payload.branch || !payload.composeFile) {
toast.error("Tüm alanları doldurun"); toast.error("Tüm alanları doldurun");
setSaving(false); setSaving(false);
return; return;
@@ -378,8 +374,48 @@ export function DeploymentsPage() {
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4"> <div className="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4">
{!isEdit && ( {!isEdit && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Proje Klasörü</Label>
<Button
variant="outline"
size="sm"
onClick={loadCandidates}
disabled={scanning}
>
{scanning ? "Taranıyor..." : "Yeniden Tara"}
</Button>
</div>
<Select
value={form.rootPath}
onValueChange={(value) => {
const candidate = candidates.find((c) => c.rootPath === value);
setForm((prev) => ({
...prev,
rootPath: value,
name: candidate?.name || prev.name,
composeFile: candidate?.composeFiles[0] || prev.composeFile
}));
}}
>
<SelectTrigger>
<SelectValue placeholder="Root altında proje seçin" />
</SelectTrigger>
<SelectContent>
{candidates.map((candidate) => (
<SelectItem key={candidate.rootPath} value={candidate.rootPath}>
{candidate.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Repo URL girildiğinde branch ve compose dosyaları listelenir. {scanning
? "Root dizin taranıyor..."
: candidates.length === 0
? "Root altında compose dosyası bulunan proje yok."
: "Compose dosyası bulunan klasörleri listeler."}
</div>
</div> </div>
)} )}
@@ -455,23 +491,15 @@ export function DeploymentsPage() {
<SelectValue placeholder="Compose seçin" /> <SelectValue placeholder="Compose seçin" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(composeOptions.length > 0 {(selectedCandidate?.composeFiles || ["docker-compose.yml", "docker-compose.dev.yml"]).map(
? composeOptions (file) => (
: ["docker-compose.yml", "docker-compose.dev.yml"]
).map((file) => (
<SelectItem key={file} value={file}> <SelectItem key={file} value={file}>
{file} {file}
</SelectItem> </SelectItem>
))} )
)}
</SelectContent> </SelectContent>
</Select> </Select>
<div className="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>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -54,7 +54,7 @@ export function HomePage() {
recentRuns: [], recentRuns: [],
totals: { successRate: 0, totalRuns: 0 } totals: { successRate: 0, totalRuns: 0 }
}); });
setError("Test metrikleri alınamadı"); setError("Job metrikleri alınamadı");
} }
if (deployResult.status === "fulfilled") { if (deployResult.status === "fulfilled") {
@@ -150,24 +150,7 @@ export function HomePage() {
.slice(0, 10); .slice(0, 10);
}, [mergedRuns, deployRuns]); }, [mergedRuns, deployRuns]);
const combinedTotals = useMemo(() => { const lastRunDuration = useMemo(() => formatDuration(mergedRuns[0]?.durationMs), [mergedRuns]);
const jobSuccess = metrics?.dailyStats.reduce((acc, d) => acc + (d.success || 0), 0) ?? 0;
const jobTotal = metrics?.dailyStats.reduce((acc, d) => acc + (d.total || 0), 0) ?? 0;
const deploySuccess =
deploymentMetrics?.dailyStats.reduce((acc, d) => acc + (d.success || 0), 0) ?? 0;
const deployTotal =
deploymentMetrics?.dailyStats.reduce((acc, d) => acc + (d.total || 0), 0) ?? 0;
const totalRuns = jobTotal + deployTotal;
const successRate = totalRuns
? Math.round(((jobSuccess + deploySuccess) / totalRuns) * 100)
: 0;
return { totalRuns, successRate };
}, [metrics, deploymentMetrics]);
const lastRunDuration = useMemo(() => {
const latest = activityItems[0];
return formatDuration(latest?.durationMs);
}, [activityItems]);
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
@@ -180,7 +163,7 @@ export function HomePage() {
</div> </div>
<div className="text-xs text-muted-foreground flex items-center gap-2"> <div className="text-xs text-muted-foreground flex items-center gap-2">
<FontAwesomeIcon icon={faClockRotateLeft} className="h-3.5 w-3.5" /> <FontAwesomeIcon icon={faClockRotateLeft} className="h-3.5 w-3.5" />
{combinedTotals.totalRuns} toplam koşu {metrics?.totals.totalRuns ?? 0} toplam koşu
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="h-48 min-w-0"> <CardContent className="h-48 min-w-0">
@@ -227,13 +210,13 @@ export function HomePage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Başarı Oranı</span> <span>Başarı Oranı</span>
<span className="text-lg font-semibold text-foreground"> <span className="text-lg font-semibold text-foreground">
{combinedTotals.successRate}% {metrics?.totals.successRate ?? 0}%
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Toplam Çalıştırma</span> <span>Toplam Çalıştırma</span>
<span className="text-lg font-semibold text-foreground"> <span className="text-lg font-semibold text-foreground">
{combinedTotals.totalRuns} {metrics?.totals.totalRuns ?? 0}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@@ -79,7 +79,7 @@ export function JobDetailPage() {
checkUnit: data.job.checkUnit checkUnit: data.job.checkUnit
}); });
}) })
.catch(() => setError("Test bulunamadı")) .catch(() => setError("Job bulunamadı"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
@@ -174,14 +174,14 @@ export function JobDetailPage() {
const handleDelete = async () => { const handleDelete = async () => {
if (!job?._id) return; if (!job?._id) return;
const ok = window.confirm("Bu testi silmek istediğinize emin misiniz?"); const ok = window.confirm("Bu job'ı silmek istediğinize emin misiniz?");
if (!ok) return; if (!ok) return;
try { try {
await deleteJob(job._id); await deleteJob(job._id);
toast.success("Test silindi"); toast.success("Job silindi");
navigate("/jobs", { replace: true }); navigate("/jobs", { replace: true });
} catch (err) { } catch (err) {
toast.error("Test silinemedi"); toast.error("Job silinemedi");
} }
}; };
@@ -203,7 +203,7 @@ export function JobDetailPage() {
return; return;
} }
await updateJob(job._id, payload); await updateJob(job._id, payload);
toast.success("Test güncellendi"); toast.success("Job güncellendi");
setJob((prev) => setJob((prev) =>
prev prev
? { ? {
@@ -281,8 +281,8 @@ export function JobDetailPage() {
className="h-10 w-10 transition hover:bg-emerald-100" className="h-10 w-10 transition hover:bg-emerald-100"
onClick={handleEdit} onClick={handleEdit}
disabled={!job} disabled={!job}
title="Testi düzenle" title="Job'ı düzenle"
aria-label="Testi düzenle" aria-label="Job'ı düzenle"
> >
<FontAwesomeIcon icon={faPen} className="h-4 w-4 text-foreground" /> <FontAwesomeIcon icon={faPen} className="h-4 w-4 text-foreground" />
</Button> </Button>
@@ -292,8 +292,8 @@ export function JobDetailPage() {
className="h-10 w-10 transition hover:bg-red-100" className="h-10 w-10 transition hover:bg-red-100"
onClick={handleDelete} onClick={handleDelete}
disabled={!job} disabled={!job}
title="Testi sil" title="Job'ı sil"
aria-label="Testi sil" aria-label="Job'ı sil"
> >
<FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-foreground" /> <FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-foreground" />
</Button> </Button>
@@ -317,7 +317,7 @@ export function JobDetailPage() {
</div> </div>
)} )}
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
<CardTitle>{job?.name || "Test Detayı"}</CardTitle> <CardTitle>{job?.name || "Job Detayı"}</CardTitle>
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-muted/50 px-3 py-1 text-xs font-semibold text-foreground"> <span className="inline-flex items-center gap-2 rounded-full border border-border bg-muted/50 px-3 py-1 text-xs font-semibold text-foreground">
<FontAwesomeIcon icon={faRepeat} className="h-3.5 w-3.5 text-foreground/80" /> <FontAwesomeIcon icon={faRepeat} className="h-3.5 w-3.5 text-foreground/80" />
{runCount} test {runCount} test
@@ -368,7 +368,7 @@ export function JobDetailPage() {
<div className="w-full max-w-lg rounded-lg border border-border bg-card card-shadow"> <div className="w-full max-w-lg rounded-lg border border-border bg-card card-shadow">
<div className="flex items-center justify-between border-b border-border px-5 py-4"> <div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="space-y-1"> <div className="space-y-1">
<div className="text-lg font-semibold text-foreground">Test Güncelle</div> <div className="text-lg font-semibold text-foreground">Job Güncelle</div>
<div className="text-sm text-muted-foreground">Değişiklikler kaydedildiğinde test yeniden tetiklenecek.</div> <div className="text-sm text-muted-foreground">Değişiklikler kaydedildiğinde test yeniden tetiklenecek.</div>
</div> </div>
<Button variant="ghost" size="icon" onClick={() => setEditOpen(false)}> <Button variant="ghost" size="icon" onClick={() => setEditOpen(false)}>
@@ -377,7 +377,7 @@ export function JobDetailPage() {
</div> </div>
<div className="space-y-4 px-5 py-4"> <div className="space-y-4 px-5 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Test Name</Label> <Label htmlFor="name">Job Name</Label>
<Input <Input
id="name" id="name"
value={form.name} value={form.name}

View File

@@ -58,7 +58,7 @@ export function JobsPage() {
const data = await fetchJobs(); const data = await fetchJobs();
setJobs(data); setJobs(data);
} catch (err) { } catch (err) {
toast.error("Testler alınamadı"); toast.error("Jobs alınamadı");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -145,11 +145,11 @@ export function JobsPage() {
j._id === updated._id ? { ...updated, runCount: j.runCount ?? updated.runCount } : j j._id === updated._id ? { ...updated, runCount: j.runCount ?? updated.runCount } : j
) )
); );
toast.success("Test güncellendi"); toast.success("Job güncellendi");
} else { } else {
const created = await createJob(payload); const created = await createJob(payload);
setJobs((prev) => [{ ...created, runCount: created.runCount ?? 0 }, ...prev]); setJobs((prev) => [{ ...created, runCount: created.runCount ?? 0 }, ...prev]);
toast.success("Test oluşturuldu"); toast.success("Job oluşturuldu");
} }
setModalOpen(false); setModalOpen(false);
} catch (err) { } catch (err) {
@@ -163,9 +163,9 @@ export function JobsPage() {
setRunningId(id); setRunningId(id);
try { try {
await runJob(id); await runJob(id);
toast.success("Test çalıştırılıyor"); toast.success("Job çalıştırılıyor");
} catch { } catch {
toast.error("Test çalıştırılamadı"); toast.error("Job çalıştırılamadı");
} finally { } finally {
setRunningId(null); setRunningId(null);
} }
@@ -185,18 +185,18 @@ export function JobsPage() {
<> <>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<h2 className="text-xl font-semibold text-foreground">Tests</h2> <h2 className="text-xl font-semibold text-foreground">Jobs</h2>
</div> </div>
<Button onClick={handleOpenNew} className="gap-2"> <Button onClick={handleOpenNew} className="gap-2">
<FontAwesomeIcon icon={faPlus} className="h-4 w-4" /> <FontAwesomeIcon icon={faPlus} className="h-4 w-4" />
New Test New Job
</Button> </Button>
</div> </div>
<div className="grid gap-4"> <div className="grid gap-4">
{loading && ( {loading && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground"> <div className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Testler yükleniyor... Jobs yükleniyor...
</div> </div>
)} )}
{!loading && jobs.length === 0 && ( {!loading && jobs.length === 0 && (
@@ -280,9 +280,9 @@ export function JobsPage() {
<div className="w-full max-w-lg rounded-lg border border-border bg-card card-shadow"> <div className="w-full max-w-lg rounded-lg border border-border bg-card card-shadow">
<div className="flex items-center justify-between border-b border-border px-5 py-4"> <div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="space-y-1"> <div className="space-y-1">
<div className="text-lg font-semibold text-foreground">{isEdit ? "Test Güncelle" : "Yeni Test"}</div> <div className="text-lg font-semibold text-foreground">{isEdit ? "Job Güncelle" : "Yeni Job"}</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Detayları girin, Tests listesi canlı olarak güncellenecek. Detayları girin, Jobs listesi canlı olarak güncellenecek.
</div> </div>
</div> </div>
<Button variant="ghost" size="icon" onClick={handleClose}> <Button variant="ghost" size="icon" onClick={handleClose}>
@@ -291,7 +291,7 @@ export function JobsPage() {
</div> </div>
<div className="space-y-4 px-5 py-4"> <div className="space-y-4 px-5 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Test Name</Label> <Label htmlFor="name">Job Name</Label>
<Input <Input
id="name" id="name"
value={form.name} value={form.name}