Compare commits
5 Commits
2b053120cb
...
deployment
| Author | SHA1 | Date | |
|---|---|---|---|
| aa12881c4b | |||
| f8d22cc082 | |||
| a43042fac1 | |||
| 0ce8559f51 | |||
| 0961751f4d |
38
README.md
38
README.md
@@ -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 Gitea’da web istemci olarak tanımlayın
|
4. Kaydettikten sonra **Webhook URL**’i Gitea’da web istemci olarak tanımlayın
|
||||||
|
|
||||||
#### Webhook Ayarları (Gitea)
|
#### Webhook Ayarları (Gitea)
|
||||||
@@ -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ı
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user