Compare commits

...

5 Commits

Author SHA1 Message Date
aa12881c4b fix(deployments): deployment kök yolunu sabitle 2026-01-19 13:31:29 +03:00
f8d22cc082 feat(deployments): repo tabanlı kurulum sistemi ekle ve root taramayı kaldır
Root dizin taraması yerine repo URL tabanlı otomatik kurulum sistemine geçiş yapıldı.
Deploy klasörü artık repo URL'sinden otomatik oluşturuluyor. Remote repo
üzerinden branch ve compose dosyası listelemesi eklendi.

- `deploymentsRoot` konfigürasyonu kaldırıldı
- `/deployments/scan` endpoint'i kaldırıldı
- `/deployments/compose-files` endpoint'i eklendi
- `repoUrl` alanı unique ve index olarak işaretlendi
- Proje oluştururken `rootPath` zorunluluğu kaldırıldı
- Deploy klasörü otomatik `deployments/{slug}` formatında oluşturuluyor
- Frontend'de root tarama UI'ı kaldırıldı, compose dosyası listeleme eklendi

BREAKING CHANGE: Root dizin tarama özelliği ve `rootPath` alanı kaldırıldı.
Artık deploymentlar sadece repo URL ile oluşturulabiliyor.
2026-01-19 12:54:33 +03:00
a43042fac1 feat(ui): birleşik metrik hesaplaması ekle
İş ve deployment istatistiklerini birleştirerek toplam koşu sayısı
ve başarı oranı göstergelerini güncelle. Son çalışma süresi
hesaplamasını activityItems kullanacak şekilde düzelt.
2026-01-18 17:28:10 +03:00
0ce8559f51 docs(env): MongoDB bağlantı örneğini güncelle 2026-01-18 17:16:30 +03:00
0961751f4d refactor(ui,docs): Job terimini Test olarak güncelle
UI ve dokümantasyon boyunca "Job" terimleri "Test" olarak değiştirildi.
Bu değişiklik, uygulamanın terminolojisini tutarlı hale getirmek
ve kullanıcı arayüzünde daha doğru bir isimlendirme sağlamak için
yapıldı. Tüm etiketler, başlıklar, bildirimler ve dokümantasyon
güncellendi.
2026-01-18 16:42:36 +03:00
12 changed files with 203 additions and 213 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ı
### 📊 Job Yönetim Sistemi ### 🧪 Test 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
- **Root Tarama**: `DEPLOYMENTS_ROOT_HOST` altında compose dosyası olan projeleri otomatik bulma - **Repo Bazlı Kurulum**: Repo URL ile proje oluşturma ve deploy klasörünü otomatik oluşturma
- **Webhook Tetikleme**: Gitea push event ile otomatik deploy - **Webhook Tetikleme**: Gitea push event ile otomatik deploy
- **Branch Seçimi**: Repo URL girince branch listesi alınır ve seçim yapılır - **Branch Seçimi**: Repo URL girince branch listesi alınır ve seçim yapılır
- **Deploy Geçmişi**: Her deploy için log ve süre kaydı - **Deploy Geçmişi**: Her deploy için log ve süre kaydı
@@ -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**: Job durumlarının anlık bildirilmesi - **Canlı Güncellemeler**: Test 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
### Job Yönetimi ### Test Yönetimi
#### Yeni Job Oluşturma #### Yeni Test Oluşturma
1. **Dashboard** menüsünden **Jobs** sayfasına gidin 1. **Dashboard** menüsünden **Tests** sayfasına gidin
2. **Yeni Job** butonuna tıklayın 2. **Yeni Test** butonuna tıklayın
3. Job bilgilerini girin: 3. Test bilgilerini girin:
- **Job Adı**: Tanımlayıcı bir isim - **Test 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
#### Job İzleme #### Test İzleme
- **Jobs Listesi**: Tüm job'ların durumunu gösterir - **Tests Listesi**: Tüm test'lerin 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**: Job'u anında tetikleme - **Manuel Çalıştırma**: Test'i anında tetikleme
### Deployment Yönetimi ### Deployment Yönetimi
1. **Deployments** sayfasına gidin 1. **Deployments** sayfasına gidin
2. **New Deployment** ile root altında taranan projeyi seçin 2. **New Deployment** ile Repo URL girin
3. Repo URL + Branch + Compose dosyasını girin 3. Branch ve Compose dosyasını seçin
4. Kaydettikten sonra **Webhook URL**i 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`
- **Job Yönetim API'leri**: CRUD operasyonları, manuel çalıştırma - **Test Yönetim API'leri**: CRUD operasyonları, manuel çalıştırma
- **Deployment API'leri**: `/deployments`, `/deployments/:id`, `/deployments/scan`, `/deployments/branches` - **Deployment API'leri**: `/deployments`, `/deployments/:id`, `/deployments/branches`, `/deployments/compose-files`
- **Webhook Endpoint**: `/api/deployments/webhook/:token` - **Webhook Endpoint**: `/api/deployments/webhook/:token`
- **WebSocket Olayları**: Real-time iletişim ve durum güncellemeleri - **WebSocket Olayları**: Real-time iletişim ve durum güncellemeleri
- **Endpoint Detayları**: Her endpoint için istek/yanıt formatları - **Endpoint Detayları**: Her endpoint için istek/yanıt formatları
@@ -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 job yönetimi - ✅ Real-time test 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 job yönetimi - 🔄 **Multi-branch Support**: Farklı branch'ler için test 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**: Job performans grafikleri - 📊 **Dashboard İstatistikleri**: Test 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://<APP_USER>:<APP_PASS>@<HOST>:27017/wisecoltci?authSource=wisecoltci # Örnek: mongodb://mongo:27017/wisecoltci
MONGO_URI=mongodb://app:change-me@mongo-host:27017/wisecoltci?authSource=wisecoltci MONGO_URI=mongodb://mongo:27017/wisecoltci
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=supersecret ADMIN_PASSWORD=supersecret
JWT_SECRET=change-me JWT_SECRET=change-me

View File

@@ -8,8 +8,7 @@ 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 }, repoUrl: { type: String, required: true, trim: true, unique: true, index: true },
branch: { type: String, required: true, trim: true }, branch: { type: String, required: true, trim: true },
composeFile: { composeFile: {
type: String, type: String,

View File

@@ -39,17 +39,6 @@ router.get("/:id/favicon", async (req, res) => {
return res.status(404).end(); return res.status(404).end();
}); });
router.get("/scan", async (req, res) => {
authMiddleware(req, res, async () => {
try {
const candidates = await deploymentService.scanRoot();
return res.json(candidates);
} catch (err) {
return res.status(500).json({ message: "Root taraması yapılamadı" });
}
});
});
router.get("/branches", async (req, res) => { router.get("/branches", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const repoUrl = req.query.repoUrl as string | undefined; const repoUrl = req.query.repoUrl as string | undefined;
@@ -65,6 +54,22 @@ 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();
@@ -123,14 +128,13 @@ router.get("/:id", async (req, res) => {
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const { name, rootPath, repoUrl, branch, composeFile, port } = req.body; const { name, repoUrl, branch, composeFile, port } = req.body;
if (!name || !rootPath || !repoUrl || !branch || !composeFile) { if (!name || !repoUrl || !branch || !composeFile) {
return res.status(400).json({ message: "Tüm alanlar gerekli" }); return res.status(400).json({ message: "Tüm alanlar gerekli" });
} }
try { try {
const created = await deploymentService.createProject({ const created = await deploymentService.createProject({
name, name,
rootPath,
repoUrl, repoUrl,
branch, branch,
composeFile, composeFile,

View File

@@ -2,7 +2,6 @@ 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,
@@ -14,14 +13,18 @@ import { Settings } from "../models/settings.js";
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"]; const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
function normalizeRoot(rootPath: string) { const deploymentsRoot = "/workspace/deployments";
return path.resolve(rootPath);
function slugify(value: string) {
return value
.toLowerCase()
.replace(/\.git$/i, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
} }
function isWithinRoot(rootPath: string, targetPath: string) { function normalizeRepoUrl(value: string) {
const resolvedRoot = normalizeRoot(rootPath); return value.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
const resolvedTarget = path.resolve(targetPath);
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`);
} }
function generateWebhookToken() { function generateWebhookToken() {
@@ -132,10 +135,7 @@ async function ensureRepo(project: DeploymentProjectDocument, onData: (line: str
} }
} }
async function runCompose( async function runCompose(project: DeploymentProjectDocument, onData: (line: string) => void) {
project: DeploymentProjectDocument,
onData: (line: string) => void
) {
const composePath = path.join(project.rootPath, project.composeFile); const composePath = path.join(project.rootPath, project.composeFile);
if (!fs.existsSync(composePath)) { if (!fs.existsSync(composePath)) {
throw new Error("Compose dosyası bulunamadı"); throw new Error("Compose dosyası bulunamadı");
@@ -153,31 +153,6 @@ async function runCompose(
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
@@ -190,6 +165,24 @@ 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;
@@ -216,27 +209,15 @@ 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 rootPath = path.resolve(input.rootPath); const repoUrl = normalizeRepoUrl(input.repoUrl);
if (!isWithinRoot(config.deploymentsRoot, rootPath)) { const existingRepo = await DeploymentProject.findOne({ repoUrl });
throw new Error("Root path deployments root dışında"); if (existingRepo) {
} throw new Error("Bu repo zaten eklenmiş");
if (!fs.existsSync(rootPath)) {
throw new Error("Root path bulunamadı");
}
const composePath = path.join(rootPath, input.composeFile);
if (!fs.existsSync(composePath)) {
throw new Error("Compose dosyası bulunamadı");
}
const existing = await DeploymentProject.findOne({ rootPath });
if (existing) {
throw new Error("Bu klasör zaten eklenmiş");
} }
let webhookToken = generateWebhookToken(); let webhookToken = generateWebhookToken();
@@ -244,11 +225,23 @@ 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: input.repoUrl, repoUrl,
branch: input.branch, branch: input.branch,
composeFile: input.composeFile, composeFile: input.composeFile,
webhookToken, webhookToken,
@@ -269,16 +262,23 @@ class DeploymentService {
) { ) {
const project = await DeploymentProject.findById(id); const project = await DeploymentProject.findById(id);
if (!project) return null; if (!project) return null;
const composePath = path.join(project.rootPath, input.composeFile); const repoUrl = normalizeRepoUrl(input.repoUrl);
if (!fs.existsSync(composePath)) { if (repoUrl !== project.repoUrl) {
throw new Error("Compose dosyası bulunamadı"); const existingRepo = await DeploymentProject.findOne({ repoUrl });
if (existingRepo && existingRepo._id.toString() !== id) {
throw new Error("Bu repo zaten eklenmiş");
}
}
const available = await this.listRemoteComposeFiles(repoUrl, input.branch);
if (!available.includes(input.composeFile)) {
throw new Error("Compose dosyası repoda bulunamadı");
} }
const env = deriveEnv(input.composeFile); const env = deriveEnv(input.composeFile);
const updated = await DeploymentProject.findByIdAndUpdate( const updated = await DeploymentProject.findByIdAndUpdate(
id, id,
{ {
name: input.name, name: input.name,
repoUrl: input.repoUrl, repoUrl,
branch: input.branch, branch: input.branch,
composeFile: input.composeFile, composeFile: input.composeFile,
env, env,

View File

@@ -54,15 +54,8 @@ 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;
@@ -81,17 +74,12 @@ export async function fetchDeploymentBranches(repoUrl: string): Promise<string[]
return (data as { branches: string[] }).branches; return (data as { branches: string[] }).branches;
} }
export async function scanDeployments(): Promise<DeploymentCandidate[]> {
const { data } = await apiClient.get("/deployments/scan");
return data as DeploymentCandidate[];
}
export async function createDeployment(payload: DeploymentInput): Promise<DeploymentProject> { export async function createDeployment(payload: DeploymentInput): Promise<DeploymentProject> {
const { data } = await apiClient.post("/deployments", payload); const { data } = await apiClient.post("/deployments", payload);
return data as DeploymentProject; return data as DeploymentProject;
} }
export async function updateDeployment(id: string, payload: Omit<DeploymentInput, "rootPath">) { export async function updateDeployment(id: string, payload: DeploymentInput) {
const { data } = await apiClient.put(`/deployments/${id}`, payload); const { data } = await apiClient.put(`/deployments/${id}`, payload);
return data as DeploymentProject; return data as DeploymentProject;
} }
@@ -113,3 +101,13 @@ 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: "Jobs", to: "/jobs", icon: faFlaskVial }, { label: "Tests", 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,13 +16,12 @@ 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";
@@ -30,7 +29,6 @@ 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"];
@@ -39,7 +37,6 @@ 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",
@@ -54,19 +51,15 @@ 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);
@@ -80,18 +73,6 @@ export function DeploymentsPage() {
} }
}; };
const loadCandidates = async () => {
setScanning(true);
try {
const data = await scanDeployments();
setCandidates(data);
} catch {
toast.error("Root taraması yapılamadı");
} finally {
setScanning(false);
}
};
useEffect(() => { useEffect(() => {
loadDeployments(); loadDeployments();
}, []); }, []);
@@ -100,6 +81,7 @@ 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 () => {
@@ -119,6 +101,30 @@ 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) {
@@ -139,16 +145,15 @@ 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, rootPath, repoUrl, branch, composeFile, port } = deployment; const { _id, name, repoUrl, branch, composeFile, port } = deployment;
setForm({ setForm({
_id, _id,
name, name,
rootPath,
repoUrl, repoUrl,
branch, branch,
composeFile, composeFile,
@@ -166,14 +171,13 @@ 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.rootPath || !payload.repoUrl || !payload.branch || !payload.composeFile) { if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) {
toast.error("Tüm alanları doldurun"); toast.error("Tüm alanları doldurun");
setSaving(false); setSaving(false);
return; return;
@@ -374,48 +378,8 @@ 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">
{scanning Repo URL girildiğinde branch ve compose dosyaları listelenir.
? "Root dizin taranıyor..."
: candidates.length === 0
? "Root altında compose dosyası bulunan proje yok."
: "Compose dosyası bulunan klasörleri listeler."}
</div>
</div> </div>
)} )}
@@ -491,15 +455,23 @@ export function DeploymentsPage() {
<SelectValue placeholder="Compose seçin" /> <SelectValue placeholder="Compose seçin" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(selectedCandidate?.composeFiles || ["docker-compose.yml", "docker-compose.dev.yml"]).map( {(composeOptions.length > 0
(file) => ( ? composeOptions
: ["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("Job metrikleri alınamadı"); setError("Test metrikleri alınamadı");
} }
if (deployResult.status === "fulfilled") { if (deployResult.status === "fulfilled") {
@@ -150,7 +150,24 @@ export function HomePage() {
.slice(0, 10); .slice(0, 10);
}, [mergedRuns, deployRuns]); }, [mergedRuns, deployRuns]);
const lastRunDuration = useMemo(() => formatDuration(mergedRuns[0]?.durationMs), [mergedRuns]); const combinedTotals = useMemo(() => {
const jobSuccess = metrics?.dailyStats.reduce((acc, d) => acc + (d.success || 0), 0) ?? 0;
const jobTotal = metrics?.dailyStats.reduce((acc, d) => acc + (d.total || 0), 0) ?? 0;
const deploySuccess =
deploymentMetrics?.dailyStats.reduce((acc, d) => acc + (d.success || 0), 0) ?? 0;
const deployTotal =
deploymentMetrics?.dailyStats.reduce((acc, d) => acc + (d.total || 0), 0) ?? 0;
const totalRuns = jobTotal + deployTotal;
const successRate = totalRuns
? Math.round(((jobSuccess + deploySuccess) / totalRuns) * 100)
: 0;
return { totalRuns, successRate };
}, [metrics, deploymentMetrics]);
const lastRunDuration = useMemo(() => {
const latest = activityItems[0];
return formatDuration(latest?.durationMs);
}, [activityItems]);
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
@@ -163,7 +180,7 @@ export function HomePage() {
</div> </div>
<div className="text-xs text-muted-foreground flex items-center gap-2"> <div className="text-xs text-muted-foreground flex items-center gap-2">
<FontAwesomeIcon icon={faClockRotateLeft} className="h-3.5 w-3.5" /> <FontAwesomeIcon icon={faClockRotateLeft} className="h-3.5 w-3.5" />
{metrics?.totals.totalRuns ?? 0} toplam koşu {combinedTotals.totalRuns} toplam koşu
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="h-48 min-w-0"> <CardContent className="h-48 min-w-0">
@@ -210,13 +227,13 @@ export function HomePage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Başarı Oranı</span> <span>Başarı Oranı</span>
<span className="text-lg font-semibold text-foreground"> <span className="text-lg font-semibold text-foreground">
{metrics?.totals.successRate ?? 0}% {combinedTotals.successRate}%
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Toplam Çalıştırma</span> <span>Toplam Çalıştırma</span>
<span className="text-lg font-semibold text-foreground"> <span className="text-lg font-semibold text-foreground">
{metrics?.totals.totalRuns ?? 0} {combinedTotals.totalRuns}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@@ -79,7 +79,7 @@ export function JobDetailPage() {
checkUnit: data.job.checkUnit checkUnit: data.job.checkUnit
}); });
}) })
.catch(() => setError("Job bulunamadı")) .catch(() => setError("Test 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 job'ı silmek istediğinize emin misiniz?"); const ok = window.confirm("Bu testi silmek istediğinize emin misiniz?");
if (!ok) return; if (!ok) return;
try { try {
await deleteJob(job._id); await deleteJob(job._id);
toast.success("Job silindi"); toast.success("Test silindi");
navigate("/jobs", { replace: true }); navigate("/jobs", { replace: true });
} catch (err) { } catch (err) {
toast.error("Job silinemedi"); toast.error("Test silinemedi");
} }
}; };
@@ -203,7 +203,7 @@ export function JobDetailPage() {
return; return;
} }
await updateJob(job._id, payload); await updateJob(job._id, payload);
toast.success("Job güncellendi"); toast.success("Test 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="Job'ı düzenle" title="Testi düzenle"
aria-label="Job'ı düzenle" aria-label="Testi 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="Job'ı sil" title="Testi sil"
aria-label="Job'ı sil" aria-label="Testi 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 || "Job Detayı"}</CardTitle> <CardTitle>{job?.name || "Test 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">Job Güncelle</div> <div className="text-lg font-semibold text-foreground">Test 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">Job Name</Label> <Label htmlFor="name">Test 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("Jobs alınamadı"); toast.error("Testler 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("Job güncellendi"); toast.success("Test 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("Job oluşturuldu"); toast.success("Test 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("Job çalıştırılıyor"); toast.success("Test çalıştırılıyor");
} catch { } catch {
toast.error("Job çalıştırılamadı"); toast.error("Test ç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">Jobs</h2> <h2 className="text-xl font-semibold text-foreground">Tests</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 Job New Test
</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">
Jobs yükleniyor... Testler 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 ? "Job Güncelle" : "Yeni Job"}</div> <div className="text-lg font-semibold text-foreground">{isEdit ? "Test Güncelle" : "Yeni Test"}</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Detayları girin, Jobs listesi canlı olarak güncellenecek. Detayları girin, Tests 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">Job Name</Label> <Label htmlFor="name">Test Name</Label>
<Input <Input
id="name" id="name"
value={form.name} value={form.name}