Compare commits

..

22 Commits

Author SHA1 Message Date
719ae4044e refactor(ui): deploy geçmişi için native scroll kullan 2026-02-03 10:08:45 +00:00
e2b9f19800 feat(ui): deploy geçmişi için scroll-area ekle 2026-02-03 10:08:18 +00:00
064a04d898 refactor(settings): ayarlar sayfası düzenini güncelle 2026-02-03 09:37:09 +00:00
1f90ce54d4 feat(settings): otomatik docker image temizliği ekle
Docker image temizliği için yapılandırılabilir zamanlayıcı ve manuel
tetikleme özelliği eklenmiştir. Kullanıcılar saat, gün veya hafta bazlı
periyotlar belirleyebilir ve anlık temizlik yapabilir.
2026-02-03 09:34:37 +00:00
b04ac03739 feat(deployments): deployment restart özelliği ekle
Deployment projeleri için yeniden başlatma (restart) yeteneği eklendi.
Backend servisi, API endpoint'i ve kullanıcı arayüzü butonları güncellendi.
2026-02-03 08:53:03 +00:00
a117275efe feat(ui): metrikleri odaklanma ve gezinme durumunda yenile
Metriklerin güncel kalması için pencere odaklanıldığında ve sayfa
gezinildiğinde verilerin yeniden yüklenmesi eklendi. Backend deployment
servisinde tip tanımları güncellendi.
2026-02-02 19:03:41 +00:00
003ddfcbd1 feat(backend): dosya sistemi tabanlı veri kalıcılığı ekle
Deployment ve job verilerinin dosya sisteminde JSON formatında saklanması
ve uygulama başladığında bu verilerin otomatik olarak yüklenmesi özelliği
eklendi.

- Deployment ve job metadata'ları dosya sisteminde saklanır
- Run geçmişi dosya sisteminde JSON olarak tutulur
- Uygulama başlangıcında dosya sistemi taranır ve eksik veriler yüklenir
- Git'ten repo URL ve branch bilgileri çıkarılabilir
- Commit mesajları normalize edilir
- Ayarlar (webhook token/secret) dosya sisteminde saklanır
2026-01-31 07:17:27 +00:00
535b5cbdc2 fix(auth): kimlik doğrulama hatasında durumu temizle
Kullanıcı verisi getirme başarısız olduğunda artık tüm kimlik doğrulama
durumunu (token, kullanıcı bilgileri) temizler, böylece eski oturum
bilgileri kalıcı olmaz.
2026-01-26 15:34:12 +00:00
2ff3fb6ee6 feat(deployments): düzenleme modalı ve deploy mesajı desteği ekle
Deployment detay sayfasında düzenleme modalı eklendi. Repo URL, branch,
compose dosyası ve environment değişkenleri inline düzenlenebilir hale
getirildi. Deploy tetikleme işlemi için özel mesaj parametresi desteği
eklendi. Düzenleme sonrası otomatik deploy tetikleme özelliği aktif edildi.
2026-01-19 17:08:50 +03:00
0092c28571 fix(ui): deployment modal layout düzenle
Modal ve sekmeler için sabit yükseklikler eklenerek
layout tutarlılığı sağlandı ve taşma sorunları giderildi.
2026-01-19 16:48:11 +03:00
fd020bd9d8 feat(deployments): environment variable desteği ekle
Deployment projelerine environment variable konfigürasyonu eklendi.
Backend tarafında DeploymentProject modeline envContent ve envExampleName
alanları eklendi. Repo içindeki .env.example dosyalarını listelemek için
yeni bir endpoint eklendi. Deployment sürecinde belirlenen env içeriği
.proje dizinine .env dosyası olarak yazılıyor.

Frontend tarafında deployment formuna "Genel" ve "Environment" sekmeleri
eklendi. Remote repodan .env.example dosyaları çekilebiliyor ve içerik
düzenlenebiliyor. Env içeriği için göster/gizle toggle'ı eklendi.
2026-01-19 15:46:22 +03:00
e7a5690d98 feat(deployments): anlık durum ve log izleme özelliği ekle
- Socket.IO tabanlı gerçek zamanlı deployment log ve durum bildirimleri ekle
- deployment:subscribe ve deployment:unsubscribe soket olaylarını destekle
- DeploymentService'e anlık durum ve log yayınlama özelliği ekle
- Deployment silinirken docker kaynaklarını temizle
- Ortam değişkenlerini tek bir .env.example dosyasında birleştir
- Docker compose yapılandırmasını güncelle (PWD ve DEPLOYMENTS_ROOT kullan)
- Repo URL'sinden proje adını otomatik öner
- Güvensiz bağlamlar için clipboard kopya fallback mekanizması ekle
- Socket.IO path'ini /api/socket.io olarak ayarla
2026-01-19 15:11:45 +03:00
a87baa653a Merge pull request 'fix(deployments): deployment kök yolunu sabitle' (#6) from deployment-dev into master
Reviewed-on: #6
2026-01-19 10:31:48 +00:00
aa12881c4b fix(deployments): deployment kök yolunu sabitle 2026-01-19 13:31:29 +03:00
a40d07917b Merge pull request 'feat(deployments): repo tabanlı kurulum sistemi ekle ve root taramayı kaldır' (#5) from deployment-dev into master
Reviewed-on: #5
2026-01-19 09:54:54 +00: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
b6f6dcdff7 Merge pull request 'feat(ui): birleşik metrik hesaplaması ekle' (#4) from deployment-dev into master
Reviewed-on: #4
2026-01-18 14:28:49 +00: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
2393078933 Merge pull request 'docs(env): MongoDB bağlantı örneğini güncelle' (#3) from deployment-dev into master
Reviewed-on: #3
2026-01-18 14:17:01 +00:00
0ce8559f51 docs(env): MongoDB bağlantı örneğini güncelle 2026-01-18 17:16:30 +03:00
2ad6431a28 Merge pull request 'refactor(ui,docs): Job terimini Test olarak güncelle' (#2) from deployment-dev into master
Reviewed-on: #2
2026-01-18 13:42:59 +00: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
32 changed files with 2299 additions and 389 deletions

View File

@@ -1,3 +1,14 @@
# Backend Environment
PORT=4000
MONGO_URI=mongodb://mongo:27017/wisecoltci
ADMIN_USERNAME=admin
ADMIN_PASSWORD=supersecret
JWT_SECRET=change-me
CLIENT_ORIGIN=http://localhost:5173
# Frontend Environment
VITE_API_URL=http://localhost:4000/api
# ---------------------------------- CLAUDE API SETTINGS ---------------------------------- # # ---------------------------------- CLAUDE API SETTINGS ---------------------------------- #
# === Claude API Config === # === Claude API Config ===
API_KEY_LITE="your-lite-key" API_KEY_LITE="your-lite-key"
@@ -7,6 +18,3 @@ ACTIVE_KEY=lite
# === Anthropic API Settings === # === Anthropic API Settings ===
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic" ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
ANTHROPIC_MODEL="glm-4.7" ANTHROPIC_MODEL="glm-4.7"
# Host üzerinde projelerin bulunduğu dizin (compose volume için, zorunludur)
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ dist
.DS_Store .DS_Store
test-runs test-runs
backend/test-runs backend/test-runs
deployments/

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,9 +0,0 @@
PORT=4000
# Prod için zorunlu Mongo bağlantısı
# Örnek: mongodb://<APP_USER>:<APP_PASS>@<HOST>:27017/wisecoltci?authSource=wisecoltci
MONGO_URI=mongodb://app:change-me@mongo-host:27017/wisecoltci?authSource=wisecoltci
ADMIN_USERNAME=admin
ADMIN_PASSWORD=supersecret
JWT_SECRET=change-me
CLIENT_ORIGIN=http://localhost:5173
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace

View File

@@ -1,4 +1,5 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
import path from "path";
dotenv.config(); dotenv.config();
@@ -9,7 +10,7 @@ export const config = {
adminPassword: process.env.ADMIN_PASSWORD || "password", adminPassword: process.env.ADMIN_PASSWORD || "password",
jwtSecret: process.env.JWT_SECRET || "changeme", jwtSecret: process.env.JWT_SECRET || "changeme",
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173", clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173",
deploymentsRoot: "/workspace" deploymentsRoot: process.env.DEPLOYMENTS_ROOT || path.join(process.cwd(), "deployments")
}; };
if (!config.jwtSecret) { if (!config.jwtSecret) {

View File

@@ -11,6 +11,8 @@ import webhookRoutes from "./routes/webhooks.js";
import { config } from "./config/env.js"; import { config } from "./config/env.js";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { jobService } from "./services/jobService.js"; import { jobService } from "./services/jobService.js";
import { deploymentService } from "./services/deploymentService.js";
import { DeploymentProject } from "./models/deploymentProject.js";
import { Job } from "./models/job.js"; import { Job } from "./models/job.js";
const app = express(); const app = express();
@@ -42,6 +44,7 @@ app.use("/api/settings", settingsRoutes);
const server = http.createServer(app); const server = http.createServer(app);
const io = new Server(server, { const io = new Server(server, {
path: "/api/socket.io",
cors: { cors: {
origin: config.clientOrigin, origin: config.clientOrigin,
methods: ["GET", "POST"] methods: ["GET", "POST"]
@@ -49,6 +52,7 @@ const io = new Server(server, {
}); });
jobService.setSocket(io); jobService.setSocket(io);
deploymentService.setSocket(io);
io.use((socket, next) => { io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined; const token = socket.handshake.auth?.token as string | undefined;
@@ -93,13 +97,40 @@ io.on("connection", (socket) => {
if (!jobId) return; if (!jobId) return;
socket.leave(`job:${jobId}`); socket.leave(`job:${jobId}`);
}); });
socket.on("deployment:subscribe", async ({ deploymentId }: { deploymentId: string }) => {
if (!deploymentId) return;
socket.join(`deployment:${deploymentId}`);
try {
const deployment = await DeploymentProject.findById(deploymentId);
if (deployment) {
socket.emit("deployment:status", {
deploymentId,
status: deployment.lastStatus,
lastRunAt: deployment.lastDeployAt,
lastMessage: deployment.lastMessage
});
}
} catch {
// sessizce geç
}
});
socket.on("deployment:unsubscribe", ({ deploymentId }: { deploymentId: string }) => {
if (!deploymentId) return;
socket.leave(`deployment:${deploymentId}`);
});
}); });
async function start() { async function start() {
try { try {
await mongoose.connect(config.mongoUri); await mongoose.connect(config.mongoUri);
console.log("MongoDB'ye bağlanıldı"); console.log("MongoDB'ye bağlanıldı");
await deploymentService.ensureSettings();
await jobService.bootstrapFromFilesystem();
await jobService.bootstrap(); await jobService.bootstrap();
await deploymentService.normalizeExistingCommitMessages();
await deploymentService.bootstrapFromFilesystem();
server.listen(config.port, () => { server.listen(config.port, () => {
console.log(`Sunucu ${config.port} portunda çalışıyor`); console.log(`Sunucu ${config.port} portunda çalışıyor`);

View File

@@ -13,6 +13,8 @@ export interface DeploymentProjectDocument extends Document {
webhookToken: string; webhookToken: string;
env: DeploymentEnv; env: DeploymentEnv;
port?: number; port?: number;
envContent?: string;
envExampleName?: string;
lastDeployAt?: Date; lastDeployAt?: Date;
lastStatus: DeploymentStatus; lastStatus: DeploymentStatus;
lastMessage?: string; lastMessage?: string;
@@ -24,7 +26,7 @@ const DeploymentProjectSchema = new Schema<DeploymentProjectDocument>(
{ {
name: { type: String, required: true, trim: true }, name: { type: String, required: true, trim: true },
rootPath: { type: String, required: true, trim: true }, rootPath: { type: String, required: true, trim: true },
repoUrl: { type: String, required: true, trim: true }, repoUrl: { type: String, required: true, trim: true, unique: true, index: true },
branch: { type: String, required: true, trim: true }, branch: { type: String, required: true, trim: true },
composeFile: { composeFile: {
type: String, type: String,
@@ -34,6 +36,8 @@ const DeploymentProjectSchema = new Schema<DeploymentProjectDocument>(
webhookToken: { type: String, required: true, unique: true, index: true }, webhookToken: { type: String, required: true, unique: true, index: true },
env: { type: String, required: true, enum: ["dev", "prod"] }, env: { type: String, required: true, enum: ["dev", "prod"] },
port: { type: Number }, port: { type: Number },
envContent: { type: String },
envExampleName: { type: String },
lastDeployAt: { type: Date }, lastDeployAt: { type: Date },
lastStatus: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" }, lastStatus: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" },
lastMessage: { type: String } lastMessage: { type: String }

View File

@@ -3,6 +3,8 @@ import mongoose, { Schema, Document } from "mongoose";
export interface SettingsDocument extends Document { export interface SettingsDocument extends Document {
webhookToken: string; webhookToken: string;
webhookSecret: string; webhookSecret: string;
cleanupIntervalValue?: number;
cleanupIntervalUnit?: "saat" | "gün" | "hafta";
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -10,7 +12,9 @@ export interface SettingsDocument extends Document {
const SettingsSchema = new Schema<SettingsDocument>( const SettingsSchema = new Schema<SettingsDocument>(
{ {
webhookToken: { type: String, required: true }, webhookToken: { type: String, required: true },
webhookSecret: { type: String, required: true } webhookSecret: { type: String, required: true },
cleanupIntervalValue: { type: Number, min: 1 },
cleanupIntervalUnit: { type: String, enum: ["saat", "gün", "hafta"] }
}, },
{ timestamps: true } { timestamps: true }
); );

View File

@@ -1,10 +1,10 @@
import { Router } from "express"; import { Router } from "express";
import fs from "fs";
import path from "path"; import path from "path";
import { authMiddleware } from "../middleware/authMiddleware.js"; import { authMiddleware } from "../middleware/authMiddleware.js";
import { deploymentService } from "../services/deploymentService.js"; import { deploymentService } from "../services/deploymentService.js";
import { DeploymentProject } from "../models/deploymentProject.js"; import { DeploymentProject } from "../models/deploymentProject.js";
import { DeploymentRun } from "../models/deploymentRun.js"; import { DeploymentRun } from "../models/deploymentRun.js";
import fs from "fs";
const router = Router(); const router = Router();
@@ -39,17 +39,6 @@ router.get("/:id/favicon", async (req, res) => {
return res.status(404).end(); return res.status(404).end();
}); });
router.get("/scan", async (req, res) => {
authMiddleware(req, res, async () => {
try {
const candidates = await deploymentService.scanRoot();
return res.json(candidates);
} catch (err) {
return res.status(500).json({ message: "Root taraması yapılamadı" });
}
});
});
router.get("/branches", async (req, res) => { router.get("/branches", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const repoUrl = req.query.repoUrl as string | undefined; const repoUrl = req.query.repoUrl as string | undefined;
@@ -65,8 +54,44 @@ router.get("/branches", async (req, res) => {
}); });
}); });
router.get("/compose-files", async (req, res) => {
authMiddleware(req, res, async () => {
const repoUrl = req.query.repoUrl as string | undefined;
const branch = req.query.branch as string | undefined;
if (!repoUrl || !branch) {
return res.status(400).json({ message: "repoUrl ve branch gerekli" });
}
try {
const files = await deploymentService.listRemoteComposeFiles(repoUrl, branch);
return res.json({ files });
} catch (err) {
return res.status(400).json({ message: "Compose listesi alınamadı", error: (err as Error).message });
}
});
});
router.get("/env-examples", async (req, res) => {
authMiddleware(req, res, async () => {
const repoUrl = req.query.repoUrl as string | undefined;
const branch = req.query.branch as string | undefined;
if (!repoUrl || !branch) {
return res.status(400).json({ message: "repoUrl ve branch gerekli" });
}
try {
const examples = await deploymentService.listRemoteEnvExamples(repoUrl, branch);
return res.json({ examples });
} catch (err) {
return res.status(400).json({ message: "Env example alınamadı", error: (err as Error).message });
}
});
});
router.get("/metrics/summary", async (req, res) => { router.get("/metrics/summary", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const deploymentCount = await DeploymentProject.countDocuments();
if (deploymentCount === 0) {
await deploymentService.bootstrapFromFilesystem();
}
const since = new Date(); const since = new Date();
since.setDate(since.getDate() - 7); since.setDate(since.getDate() - 7);
@@ -103,7 +128,11 @@ router.get("/metrics/summary", async (req, res) => {
router.get("/", async (_req, res) => { router.get("/", async (_req, res) => {
authMiddleware(_req, res, async () => { authMiddleware(_req, res, async () => {
const projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean(); let projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean();
if (projects.length === 0) {
await deploymentService.bootstrapFromFilesystem();
projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean();
}
return res.json(projects); return res.json(projects);
}); });
}); });
@@ -123,19 +152,23 @@ router.get("/:id", async (req, res) => {
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const { name, rootPath, repoUrl, branch, composeFile, port } = req.body; const { name, repoUrl, branch, composeFile, port, envContent, envExampleName } = req.body;
if (!name || !rootPath || !repoUrl || !branch || !composeFile) { if (!name || !repoUrl || !branch || !composeFile) {
return res.status(400).json({ message: "Tüm alanlar gerekli" }); return res.status(400).json({ message: "Tüm alanlar gerekli" });
} }
try { try {
const created = await deploymentService.createProject({ const created = await deploymentService.createProject({
name, name,
rootPath,
repoUrl, repoUrl,
branch, branch,
composeFile, composeFile,
port port,
envContent,
envExampleName
}); });
deploymentService
.runDeployment(created._id.toString(), { message: "First deployment" })
.catch(() => undefined);
return res.status(201).json(created); return res.status(201).json(created);
} catch (err) { } catch (err) {
return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message }); return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message });
@@ -146,7 +179,7 @@ router.post("/", async (req, res) => {
router.put("/:id", async (req, res) => { router.put("/:id", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const { id } = req.params; const { id } = req.params;
const { name, repoUrl, branch, composeFile, port } = req.body; const { name, repoUrl, branch, composeFile, port, envContent, envExampleName } = req.body;
if (!name || !repoUrl || !branch || !composeFile) { if (!name || !repoUrl || !branch || !composeFile) {
return res.status(400).json({ message: "Tüm alanlar gerekli" }); return res.status(400).json({ message: "Tüm alanlar gerekli" });
} }
@@ -156,7 +189,9 @@ router.put("/:id", async (req, res) => {
repoUrl, repoUrl,
branch, branch,
composeFile, composeFile,
port port,
envContent,
envExampleName
}); });
if (!updated) return res.status(404).json({ message: "Deployment bulunamadı" }); if (!updated) return res.status(404).json({ message: "Deployment bulunamadı" });
return res.json(updated); return res.json(updated);
@@ -170,9 +205,12 @@ router.delete("/:id", async (req, res) => {
authMiddleware(req, res, async () => { authMiddleware(req, res, async () => {
const { id } = req.params; const { id } = req.params;
try { try {
const deleted = await DeploymentProject.findByIdAndDelete(id); const project = await DeploymentProject.findById(id);
if (!deleted) return res.status(404).json({ message: "Deployment bulunamadı" }); if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
await deploymentService.cleanupProjectResources(project);
await DeploymentProject.findByIdAndDelete(id);
await DeploymentRun.deleteMany({ project: id }); await DeploymentRun.deleteMany({ project: id });
await fs.promises.rm(project.rootPath, { recursive: true, force: true });
return res.json({ success: true }); return res.json({ success: true });
} catch (err) { } catch (err) {
return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message }); return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message });
@@ -185,7 +223,25 @@ router.post("/:id/run", async (req, res) => {
const { id } = req.params; const { id } = req.params;
const project = await DeploymentProject.findById(id); const project = await DeploymentProject.findById(id);
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" }); if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
deploymentService.runDeployment(id).catch(() => undefined); const rawMessage = typeof req.body?.message === "string" ? req.body.message.trim() : "";
const message = rawMessage || "manual deploy trigger";
deploymentService
.runDeployment(id, { message })
.catch(() => undefined);
return res.json({ queued: true });
});
});
router.post("/:id/restart", async (req, res) => {
authMiddleware(req, res, async () => {
const { id } = req.params;
const project = await DeploymentProject.findById(id);
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
const rawMessage = typeof req.body?.message === "string" ? req.body.message.trim() : "";
const message = rawMessage || "restart";
deploymentService
.restartDeployment(id, { message })
.catch(() => undefined);
return res.json({ queued: true }); return res.json({ queued: true });
}); });
}); });

View File

@@ -9,7 +9,11 @@ const router = Router();
router.use(authMiddleware); router.use(authMiddleware);
router.get("/", async (_req, res) => { router.get("/", async (_req, res) => {
const jobs = await Job.find().sort({ createdAt: -1 }).lean(); let jobs = await Job.find().sort({ createdAt: -1 }).lean();
if (jobs.length === 0) {
await jobService.bootstrapFromFilesystem();
jobs = await Job.find().sort({ createdAt: -1 }).lean();
}
const counts = await JobRun.aggregate([ const counts = await JobRun.aggregate([
{ $group: { _id: "$job", runCount: { $sum: 1 } } } { $group: { _id: "$job", runCount: { $sum: 1 } } }
]); ]);
@@ -26,6 +30,10 @@ router.get("/", async (_req, res) => {
}); });
router.get("/metrics/summary", async (_req, res) => { router.get("/metrics/summary", async (_req, res) => {
const jobCount = await Job.countDocuments();
if (jobCount === 0) {
await jobService.bootstrapFromFilesystem();
}
const since = new Date(); const since = new Date();
since.setDate(since.getDate() - 7); since.setDate(since.getDate() - 7);
@@ -87,6 +95,7 @@ router.post("/", async (req, res) => {
} }
try { try {
const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit }); const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit });
await jobService.persistMetadata(job);
jobService.scheduleJob(job); jobService.scheduleJob(job);
// Yeni job oluşturulduğunda ilk test otomatik tetiklensin // Yeni job oluşturulduğunda ilk test otomatik tetiklensin
jobService.runJob(job._id.toString()).catch(() => undefined); jobService.runJob(job._id.toString()).catch(() => undefined);
@@ -106,6 +115,7 @@ router.put("/:id", async (req, res) => {
{ new: true, runValidators: true } { new: true, runValidators: true }
); );
if (!job) return res.status(404).json({ message: "Job bulunamadı" }); if (!job) return res.status(404).json({ message: "Job bulunamadı" });
await jobService.persistMetadata(job);
jobService.scheduleJob(job); jobService.scheduleJob(job);
return res.json(job); return res.json(job);
} catch (err) { } catch (err) {

View File

@@ -11,6 +11,8 @@ router.get("/", async (_req, res) => {
return res.json({ return res.json({
webhookToken: settings.webhookToken, webhookToken: settings.webhookToken,
webhookSecret: settings.webhookSecret, webhookSecret: settings.webhookSecret,
cleanupIntervalValue: settings.cleanupIntervalValue,
cleanupIntervalUnit: settings.cleanupIntervalUnit,
updatedAt: settings.updatedAt updatedAt: settings.updatedAt
}); });
}); });
@@ -31,4 +33,29 @@ router.post("/secret/rotate", async (_req, res) => {
}); });
}); });
router.post("/cleanup-interval", async (req, res) => {
const settings = await deploymentService.ensureSettings();
const { value, unit } = req.body as {
value?: number;
unit?: "saat" | "gün" | "hafta";
};
if (!value || value < 1 || !unit) {
return res.status(400).json({ message: "Geçerli periyot gerekli" });
}
settings.cleanupIntervalValue = value;
settings.cleanupIntervalUnit = unit;
await settings.save();
await deploymentService.updateCleanupSchedule(value, unit);
return res.json({
cleanupIntervalValue: settings.cleanupIntervalValue,
cleanupIntervalUnit: settings.cleanupIntervalUnit,
updatedAt: settings.updatedAt
});
});
router.post("/cleanup-images", async (_req, res) => {
await deploymentService.cleanupUnusedImages();
return res.json({ success: true });
});
export default router; export default router;

View File

@@ -1,6 +1,6 @@
import { Router, Request } from "express"; import { Router, Request } from "express";
import crypto from "crypto"; import crypto from "crypto";
import { deploymentService } from "../services/deploymentService.js"; import { deploymentService, normalizeCommitMessage } from "../services/deploymentService.js";
const router = Router(); const router = Router();
@@ -18,6 +18,12 @@ function verifySignature(rawBody: Buffer, secret: string, signature: string) {
return crypto.timingSafeEqual(Buffer.from(cleaned), Buffer.from(expected)); return crypto.timingSafeEqual(Buffer.from(cleaned), Buffer.from(expected));
} }
function normalizeBranch(value: string | undefined) {
const raw = (value || "").trim();
if (!raw) return "";
return raw.startsWith("refs/heads/") ? raw.replace("refs/heads/", "") : raw;
}
router.post("/api/deployments/webhook/:token", async (req, res) => { router.post("/api/deployments/webhook/:token", async (req, res) => {
const { token } = req.params; const { token } = req.params;
const settings = await deploymentService.ensureSettings(); const settings = await deploymentService.ensureSettings();
@@ -46,14 +52,16 @@ router.post("/api/deployments/webhook/:token", async (req, res) => {
const payload = req.body as { ref?: string; head_commit?: { message?: string }; commits?: Array<{ message?: string }> }; const payload = req.body as { ref?: string; head_commit?: { message?: string }; commits?: Array<{ message?: string }> };
const ref = payload?.ref || ""; const ref = payload?.ref || "";
const branch = ref.startsWith("refs/heads/") ? ref.replace("refs/heads/", "") : ref; const branch = normalizeBranch(ref);
const commitMessage = const commitMessageRaw =
payload?.head_commit?.message || payload?.commits?.[payload.commits.length - 1]?.message; payload?.head_commit?.message || payload?.commits?.[payload.commits.length - 1]?.message;
const commitMessage = normalizeCommitMessage(commitMessageRaw);
const project = await deploymentService.findByWebhookToken(token); const project = await deploymentService.findByWebhookToken(token);
if (!project) return res.status(404).json({ message: "Deployment bulunamadi" }); if (!project) return res.status(404).json({ message: "Deployment bulunamadi" });
if (branch && branch !== project.branch) { const projectBranch = normalizeBranch(project.branch);
if (projectBranch && projectBranch !== "*" && branch && branch !== projectBranch) {
return res.json({ ignored: true }); return res.json({ ignored: true });
} }

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 { Server } from "socket.io";
import { config } from "../config/env.js"; import { config } from "../config/env.js";
import { import {
DeploymentProject, DeploymentProject,
@@ -9,19 +10,204 @@ import {
ComposeFile, ComposeFile,
DeploymentEnv DeploymentEnv
} from "../models/deploymentProject.js"; } from "../models/deploymentProject.js";
import { DeploymentRun } from "../models/deploymentRun.js"; import { DeploymentRun, DeploymentRunDocument } from "../models/deploymentRun.js";
import { Settings } from "../models/settings.js"; import { Settings } from "../models/settings.js";
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"]; const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
function normalizeRoot(rootPath: string) { const deploymentsRoot = config.deploymentsRoot;
return path.resolve(rootPath); const metadataFileName = ".wisecolt-ci.json";
const settingsFileName = ".wisecolt-ci-settings.json";
const runsDirName = ".wisecolt-ci-runs";
export function normalizeCommitMessage(message?: string) {
if (!message) return undefined;
const firstLine = message.split(/\r?\n/)[0]?.trim();
return firstLine || undefined;
} }
function isWithinRoot(rootPath: string, targetPath: string) { type DeploymentMetadata = {
const resolvedRoot = normalizeRoot(rootPath); name: string;
const resolvedTarget = path.resolve(targetPath); repoUrl: string;
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`); branch: string;
composeFile: ComposeFile;
webhookToken: string;
env: DeploymentEnv;
port?: number;
envContent?: string;
envExampleName?: string;
};
type SettingsMetadata = {
webhookToken: string;
webhookSecret: string;
cleanupIntervalValue?: number;
cleanupIntervalUnit?: "saat" | "gün" | "hafta";
};
type StoredRun = {
status: "running" | "success" | "failed";
message?: string;
logs: string[];
startedAt: string;
finishedAt?: string;
durationMs?: number;
createdAt: string;
updatedAt: string;
};
async function readMetadata(repoDir: string): Promise<DeploymentMetadata | null> {
const filePath = path.join(repoDir, metadataFileName);
if (!fs.existsSync(filePath)) return null;
try {
const raw = await fs.promises.readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as DeploymentMetadata;
if (!parsed?.repoUrl || !parsed?.composeFile) return null;
return parsed;
} catch {
return null;
}
}
async function writeMetadata(repoDir: string, data: DeploymentMetadata) {
const filePath = path.join(repoDir, metadataFileName);
const payload = JSON.stringify(data, null, 2);
await fs.promises.writeFile(filePath, payload, "utf8");
}
function getRunsDir(repoDir: string) {
return path.join(repoDir, runsDirName);
}
function serializeRun(run: DeploymentRunDocument) {
return {
status: run.status,
message: run.message,
logs: run.logs || [],
startedAt: new Date(run.startedAt).toISOString(),
finishedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : undefined,
durationMs: run.durationMs,
createdAt: new Date(run.createdAt).toISOString(),
updatedAt: new Date(run.updatedAt).toISOString()
} satisfies StoredRun;
}
async function writeRunFile(repoDir: string, run: DeploymentRunDocument) {
const dir = getRunsDir(repoDir);
await fs.promises.mkdir(dir, { recursive: true });
const data = serializeRun(run);
const name = `${new Date(data.startedAt).getTime()}-${run._id.toString()}.json`;
const filePath = path.join(dir, name);
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
}
async function readStoredRuns(repoDir: string): Promise<StoredRun[]> {
const dir = getRunsDir(repoDir);
if (!fs.existsSync(dir)) return [];
const entries = await fs.promises.readdir(dir);
const items: StoredRun[] = [];
for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
try {
const raw = await fs.promises.readFile(path.join(dir, entry), "utf8");
const parsed = JSON.parse(raw) as StoredRun;
if (!parsed?.startedAt || !parsed?.status) continue;
items.push(parsed);
} catch {
// ignore invalid file
}
}
return items;
}
async function readSettingsFile(): Promise<SettingsMetadata | null> {
const filePath = path.join(deploymentsRoot, settingsFileName);
if (!fs.existsSync(filePath)) return null;
try {
const raw = await fs.promises.readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as SettingsMetadata;
if (!parsed?.webhookToken || !parsed?.webhookSecret) return null;
return parsed;
} catch {
return null;
}
}
async function writeSettingsFile(data: SettingsMetadata) {
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
const filePath = path.join(deploymentsRoot, settingsFileName);
const payload = JSON.stringify(data, null, 2);
await fs.promises.writeFile(filePath, payload, "utf8");
}
function inferComposeFile(repoDir: string): ComposeFile | null {
const prod = path.join(repoDir, "docker-compose.yml");
if (fs.existsSync(prod)) return "docker-compose.yml";
const dev = path.join(repoDir, "docker-compose.dev.yml");
if (fs.existsSync(dev)) return "docker-compose.dev.yml";
return null;
}
async function inferRepoUrlFromGit(repoDir: string): Promise<string | null> {
const gitConfig = path.join(repoDir, ".git", "config");
if (!fs.existsSync(gitConfig)) return null;
try {
const content = await fs.promises.readFile(gitConfig, "utf8");
const lines = content.split(/\r?\n/);
let inOrigin = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("[remote \"")) {
inOrigin = trimmed === "[remote \"origin\"]";
continue;
}
if (!inOrigin) continue;
if (trimmed.startsWith("url")) {
const parts = trimmed.split("=");
const value = parts.slice(1).join("=").trim();
return value || null;
}
}
return null;
} catch {
return null;
}
}
async function inferBranchFromGit(repoDir: string): Promise<string | null> {
const headPath = path.join(repoDir, ".git", "HEAD");
if (!fs.existsSync(headPath)) return null;
try {
const head = (await fs.promises.readFile(headPath, "utf8")).trim();
if (!head.startsWith("ref:")) return null;
const ref = head.replace("ref:", "").trim();
const prefix = "refs/heads/";
if (ref.startsWith(prefix)) {
return ref.slice(prefix.length);
}
return null;
} catch {
return null;
}
}
function inferName(repoUrl: string, rootPath: string) {
const normalized = repoUrl.replace(/\/+$/, "");
const lastPart = normalized.split("/").pop() || "";
const cleaned = lastPart.replace(/\.git$/i, "");
return cleaned || path.basename(rootPath);
}
function slugify(value: string) {
return value
.toLowerCase()
.replace(/\.git$/i, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function normalizeRepoUrl(value: string) {
return value.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
} }
function generateWebhookToken() { function generateWebhookToken() {
@@ -115,11 +301,37 @@ async function ensureRepo(project: DeploymentProjectDocument, onData: (line: str
if (!exists) { if (!exists) {
const entries = await fs.promises.readdir(repoDir); const entries = await fs.promises.readdir(repoDir);
if (entries.length > 0) { const allowed = new Set<string>([metadataFileName, ".env", ".env.local", runsDirName]);
const blocking = entries.filter((name) => !allowed.has(name));
if (blocking.length > 0) {
throw new Error("Repo klasoru git olmayan dosyalar iceriyor"); throw new Error("Repo klasoru git olmayan dosyalar iceriyor");
} }
let envBackup: string | null = null;
const envPath = path.join(repoDir, ".env");
if (fs.existsSync(envPath)) {
envBackup = await fs.promises.readFile(envPath, "utf8");
}
await Promise.all(
entries
.filter((name) => allowed.has(name))
.map((name) => fs.promises.rm(path.join(repoDir, name), { force: true }))
);
onData(`Repo klonlanıyor: ${project.repoUrl}`); onData(`Repo klonlanıyor: ${project.repoUrl}`);
await runCommand(`git clone --branch ${project.branch} ${project.repoUrl} .`, repoDir, onData); await runCommand(`git clone --branch ${project.branch} ${project.repoUrl} .`, repoDir, onData);
if (envBackup) {
await fs.promises.writeFile(envPath, envBackup, "utf8");
}
await writeMetadata(repoDir, {
name: project.name,
repoUrl: project.repoUrl,
branch: project.branch,
composeFile: project.composeFile,
webhookToken: project.webhookToken,
env: project.env,
port: project.port,
envContent: project.envContent,
envExampleName: project.envExampleName
});
} else { } else {
onData("Repo güncelleniyor (git fetch/pull)..."); onData("Repo güncelleniyor (git fetch/pull)...");
await runCommand(`git fetch origin ${project.branch}`, repoDir, onData); await runCommand(`git fetch origin ${project.branch}`, repoDir, onData);
@@ -132,10 +344,7 @@ async function ensureRepo(project: DeploymentProjectDocument, onData: (line: str
} }
} }
async function runCompose( async function runCompose(project: DeploymentProjectDocument, onData: (line: string) => void) {
project: DeploymentProjectDocument,
onData: (line: string) => void
) {
const composePath = path.join(project.rootPath, project.composeFile); const composePath = path.join(project.rootPath, project.composeFile);
if (!fs.existsSync(composePath)) { if (!fs.existsSync(composePath)) {
throw new Error("Compose dosyası bulunamadı"); throw new Error("Compose dosyası bulunamadı");
@@ -152,31 +361,40 @@ async function runCompose(
class DeploymentService { class DeploymentService {
private running: Map<string, boolean> = new Map(); private running: Map<string, boolean> = new Map();
private io: Server | null = null;
private cleanupTimer: NodeJS.Timeout | null = null;
async scanRoot() { setSocket(io: Server) {
const rootPath = normalizeRoot(config.deploymentsRoot); this.io = io;
if (!fs.existsSync(rootPath)) {
throw new Error("Deployments root bulunamadı");
} }
const entries = await fs.promises.readdir(rootPath, { withFileTypes: true });
const candidates = [];
for (const entry of entries) { private async emitStatus(deploymentId: string, payload: Partial<DeploymentProjectDocument>) {
if (!entry.isDirectory()) continue; if (!this.io) return;
if (entry.name.startsWith(".")) continue; const runCount = await DeploymentRun.countDocuments({ project: deploymentId });
const folderPath = path.join(rootPath, entry.name); const body = {
const available = composeFileCandidates.filter((file) => deploymentId,
fs.existsSync(path.join(folderPath, file)) status: payload.lastStatus,
); lastRunAt: payload.lastDeployAt,
if (available.length === 0) continue; lastMessage: payload.lastMessage,
candidates.push({ runCount
name: entry.name, };
rootPath: folderPath, this.io.to(`deployment:${deploymentId}`).emit("deployment:status", body);
composeFiles: available this.io.emit("deployment:status", body);
}
private emitLog(deploymentId: string, line: string) {
if (!this.io) return;
this.io.to(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
this.io.except(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
}
private emitRun(deploymentId: string, run: DeploymentRunDocument) {
if (!this.io) return;
this.io.to(`deployment:${deploymentId}`).emit("deployment:run", {
deploymentId,
run
}); });
} }
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());
@@ -190,13 +408,83 @@ class DeploymentService {
return branches; return branches;
} }
async listRemoteComposeFiles(repoUrl: string, branch: string) {
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
const tmpBase = await fs.promises.mkdtemp(path.join(deploymentsRoot, ".tmp-"));
try {
await runCommand(
`git clone --depth 1 --single-branch --branch ${branch} ${repoUrl} ${tmpBase}`,
process.cwd(),
() => undefined
);
const available = composeFileCandidates.filter((file) =>
fs.existsSync(path.join(tmpBase, file))
);
return available;
} finally {
await fs.promises.rm(tmpBase, { recursive: true, force: true });
}
}
async listRemoteEnvExamples(repoUrl: string, branch: string) {
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
const tmpBase = await fs.promises.mkdtemp(path.join(deploymentsRoot, ".tmp-"));
try {
await runCommand(
`git clone --depth 1 --single-branch --branch ${branch} ${repoUrl} ${tmpBase}`,
process.cwd(),
() => undefined
);
const entries = await fs.promises.readdir(tmpBase, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((name) => name.toLowerCase().endsWith(".env.example"));
const items = await Promise.all(
files.map(async (name) => ({
name,
content: await fs.promises.readFile(path.join(tmpBase, name), "utf8")
}))
);
return items;
} finally {
await fs.promises.rm(tmpBase, { recursive: true, force: true });
}
}
async ensureSettings() { async ensureSettings() {
const existing = await Settings.findOne(); const existing = await Settings.findOne();
if (existing) return existing; if (existing) {
await this.updateCleanupSchedule(existing.cleanupIntervalValue, existing.cleanupIntervalUnit);
return existing;
}
const fileSettings = await readSettingsFile();
if (fileSettings) {
const createdFromFile = await Settings.create({
webhookToken: fileSettings.webhookToken,
webhookSecret: fileSettings.webhookSecret,
cleanupIntervalValue: fileSettings.cleanupIntervalValue,
cleanupIntervalUnit: fileSettings.cleanupIntervalUnit
});
await this.updateCleanupSchedule(
createdFromFile.cleanupIntervalValue,
createdFromFile.cleanupIntervalUnit
);
return createdFromFile;
}
const created = await Settings.create({ const created = await Settings.create({
webhookToken: generateApiToken(), webhookToken: generateApiToken(),
webhookSecret: generateSecret() webhookSecret: generateSecret()
}); });
await writeSettingsFile({
webhookToken: created.webhookToken,
webhookSecret: created.webhookSecret,
cleanupIntervalValue: created.cleanupIntervalValue,
cleanupIntervalUnit: created.cleanupIntervalUnit
});
await this.updateCleanupSchedule(created.cleanupIntervalValue, created.cleanupIntervalUnit);
return created; return created;
} }
@@ -204,6 +492,12 @@ class DeploymentService {
const settings = await this.ensureSettings(); const settings = await this.ensureSettings();
settings.webhookToken = generateApiToken(); settings.webhookToken = generateApiToken();
await settings.save(); await settings.save();
await writeSettingsFile({
webhookToken: settings.webhookToken,
webhookSecret: settings.webhookSecret,
cleanupIntervalValue: settings.cleanupIntervalValue,
cleanupIntervalUnit: settings.cleanupIntervalUnit
});
return settings; return settings;
} }
@@ -211,32 +505,50 @@ class DeploymentService {
const settings = await this.ensureSettings(); const settings = await this.ensureSettings();
settings.webhookSecret = generateSecret(); settings.webhookSecret = generateSecret();
await settings.save(); await settings.save();
await writeSettingsFile({
webhookToken: settings.webhookToken,
webhookSecret: settings.webhookSecret,
cleanupIntervalValue: settings.cleanupIntervalValue,
cleanupIntervalUnit: settings.cleanupIntervalUnit
});
return settings; return settings;
} }
async updateCleanupSchedule(value?: number, unit?: "saat" | "gün" | "hafta") {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
if (!value || !unit) return;
const intervalMs =
unit === "saat"
? value * 60 * 60 * 1000
: unit === "gün"
? value * 24 * 60 * 60 * 1000
: value * 7 * 24 * 60 * 60 * 1000;
if (!intervalMs || Number.isNaN(intervalMs)) return;
this.cleanupTimer = setInterval(() => {
this.cleanupUnusedImages().catch(() => undefined);
}, intervalMs);
}
async cleanupUnusedImages() {
await runCommand("docker image prune -a -f", process.cwd(), () => undefined);
}
async createProject(input: { async createProject(input: {
name: string; name: string;
rootPath: string;
repoUrl: string; repoUrl: string;
branch: string; branch: string;
composeFile: ComposeFile; composeFile: ComposeFile;
port?: number; port?: number;
envContent?: string;
envExampleName?: string;
}) { }) {
const rootPath = path.resolve(input.rootPath); const repoUrl = normalizeRepoUrl(input.repoUrl);
if (!isWithinRoot(config.deploymentsRoot, rootPath)) { const existingRepo = await DeploymentProject.findOne({ repoUrl });
throw new Error("Root path deployments root dışında"); if (existingRepo) {
} throw new Error("Bu repo zaten eklenmiş");
if (!fs.existsSync(rootPath)) {
throw new Error("Root path bulunamadı");
}
const composePath = path.join(rootPath, input.composeFile);
if (!fs.existsSync(composePath)) {
throw new Error("Compose dosyası bulunamadı");
}
const existing = await DeploymentProject.findOne({ rootPath });
if (existing) {
throw new Error("Bu klasör zaten eklenmiş");
} }
let webhookToken = generateWebhookToken(); let webhookToken = generateWebhookToken();
@@ -244,17 +556,43 @@ class DeploymentService {
webhookToken = generateWebhookToken(); webhookToken = generateWebhookToken();
} }
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
const baseName = slugify(path.basename(repoUrl));
const suffix = crypto.randomBytes(3).toString("hex");
const slug = baseName ? `${baseName}-${suffix}` : `deployment-${suffix}`;
const rootPath = path.join(deploymentsRoot, slug);
await fs.promises.mkdir(rootPath, { recursive: true });
const available = await this.listRemoteComposeFiles(repoUrl, input.branch);
if (!available.includes(input.composeFile)) {
throw new Error("Compose dosyası repoda bulunamadı");
}
const env = deriveEnv(input.composeFile); const env = deriveEnv(input.composeFile);
return DeploymentProject.create({ const created = await DeploymentProject.create({
name: input.name, name: input.name,
rootPath, rootPath,
repoUrl: input.repoUrl, repoUrl,
branch: input.branch, branch: input.branch,
composeFile: input.composeFile, composeFile: input.composeFile,
webhookToken, webhookToken,
env, env,
port: input.port port: input.port,
envContent: input.envContent,
envExampleName: input.envExampleName
}); });
await writeMetadata(rootPath, {
name: created.name,
repoUrl: created.repoUrl,
branch: created.branch,
composeFile: created.composeFile,
webhookToken: created.webhookToken,
env: created.env,
port: created.port,
envContent: created.envContent,
envExampleName: created.envExampleName
});
return created;
} }
async updateProject( async updateProject(
@@ -265,27 +603,51 @@ class DeploymentService {
branch: string; branch: string;
composeFile: ComposeFile; composeFile: ComposeFile;
port?: number; port?: number;
envContent?: string;
envExampleName?: string;
} }
) { ) {
const project = await DeploymentProject.findById(id); const project = await DeploymentProject.findById(id);
if (!project) return null; if (!project) return null;
const composePath = path.join(project.rootPath, input.composeFile); const repoUrl = normalizeRepoUrl(input.repoUrl);
if (!fs.existsSync(composePath)) { if (repoUrl !== project.repoUrl) {
throw new Error("Compose dosyası bulunamadı"); const existingRepo = await DeploymentProject.findOne({ repoUrl });
if (existingRepo && existingRepo._id.toString() !== id) {
throw new Error("Bu repo zaten eklenmiş");
}
}
const available = await this.listRemoteComposeFiles(repoUrl, input.branch);
if (!available.includes(input.composeFile)) {
throw new Error("Compose dosyası repoda bulunamadı");
} }
const env = deriveEnv(input.composeFile); const env = deriveEnv(input.composeFile);
const updated = await DeploymentProject.findByIdAndUpdate( const updated = await DeploymentProject.findByIdAndUpdate(
id, id,
{ {
name: input.name, name: input.name,
repoUrl: input.repoUrl, repoUrl,
branch: input.branch, branch: input.branch,
composeFile: input.composeFile, composeFile: input.composeFile,
env, env,
port: input.port port: input.port,
envContent: input.envContent,
envExampleName: input.envExampleName
}, },
{ new: true, runValidators: true } { new: true, runValidators: true }
); );
if (updated) {
await writeMetadata(updated.rootPath, {
name: updated.name,
repoUrl: updated.repoUrl,
branch: updated.branch,
composeFile: updated.composeFile,
webhookToken: updated.webhookToken,
env: updated.env,
port: updated.port,
envContent: updated.envContent,
envExampleName: updated.envExampleName
});
}
return updated; return updated;
} }
@@ -301,41 +663,61 @@ class DeploymentService {
return; return;
} }
const normalizedMessage = normalizeCommitMessage(options?.message);
const startedAt = Date.now(); const startedAt = Date.now();
const runLogs: string[] = []; const runLogs: string[] = [];
const pushLog = (line: string) => { const pushLog = (line: string) => {
runLogs.push(line); runLogs.push(line);
this.emitLog(projectId, line);
}; };
const runDoc = await DeploymentRun.create({ const runDoc = await DeploymentRun.create({
project: projectId, project: projectId,
status: "running", status: "running",
startedAt: new Date(), startedAt: new Date(),
message: options?.message message: normalizedMessage ?? options?.message
}); });
this.emitRun(projectId, runDoc);
await writeRunFile(project.rootPath, runDoc);
await DeploymentProject.findByIdAndUpdate(projectId, { await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "running", lastStatus: "running",
lastMessage: options?.message || "Deploy başlıyor..." lastMessage: normalizedMessage ?? options?.message ?? "Deploy başlıyor..."
}); });
await this.emitStatus(projectId, {
lastStatus: "running",
lastMessage: normalizedMessage ?? options?.message ?? "Deploy başlıyor..."
} as DeploymentProjectDocument);
try { try {
await ensureRepo(project, (line) => pushLog(line)); await ensureRepo(project, (line) => pushLog(line));
if (project.envContent) {
await fs.promises.writeFile(path.join(project.rootPath, ".env"), project.envContent, "utf8");
pushLog(".env güncellendi");
}
pushLog("Deploy komutları çalıştırılıyor..."); pushLog("Deploy komutları çalıştırılıyor...");
await runCompose(project, (line) => pushLog(line)); await runCompose(project, (line) => pushLog(line));
const duration = Date.now() - startedAt; const duration = Date.now() - startedAt;
await DeploymentProject.findByIdAndUpdate(projectId, { await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "success", lastStatus: "success",
lastDeployAt: new Date(), lastDeployAt: new Date(),
lastMessage: options?.message || "Başarılı" lastMessage: normalizedMessage ?? options?.message ?? "Başarılı"
}); });
await this.emitStatus(projectId, {
lastStatus: "success",
lastDeployAt: new Date(),
lastMessage: normalizedMessage ?? options?.message ?? "Başarılı"
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, { await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "success", status: "success",
finishedAt: new Date(), finishedAt: new Date(),
durationMs: duration, durationMs: duration,
logs: runLogs, logs: runLogs,
message: options?.message message: normalizedMessage ?? options?.message
}); });
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
pushLog("Deploy tamamlandı: Başarılı"); pushLog("Deploy tamamlandı: Başarılı");
} catch (err) { } catch (err) {
const duration = Date.now() - startedAt; const duration = Date.now() - startedAt;
@@ -344,22 +726,258 @@ class DeploymentService {
lastDeployAt: new Date(), lastDeployAt: new Date(),
lastMessage: (err as Error).message lastMessage: (err as Error).message
}); });
await this.emitStatus(projectId, {
lastStatus: "failed",
lastDeployAt: new Date(),
lastMessage: (err as Error).message
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, { await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "failed", status: "failed",
finishedAt: new Date(), finishedAt: new Date(),
durationMs: duration, durationMs: duration,
logs: runLogs, logs: runLogs,
message: options?.message message: normalizedMessage ?? options?.message
}); });
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
pushLog(`Hata: ${(err as Error).message}`); pushLog(`Hata: ${(err as Error).message}`);
} finally { } finally {
this.running.delete(projectId); this.running.delete(projectId);
} }
} }
async restartDeployment(projectId: string, options?: { message?: string }) {
if (this.running.get(projectId)) {
return;
}
this.running.set(projectId, true);
const project = await DeploymentProject.findById(projectId);
if (!project) {
this.running.delete(projectId);
return;
}
const normalizedMessage = normalizeCommitMessage(options?.message);
const startedAt = Date.now();
const runLogs: string[] = [];
const pushLog = (line: string) => {
runLogs.push(line);
this.emitLog(projectId, line);
};
const runDoc = await DeploymentRun.create({
project: projectId,
status: "running",
startedAt: new Date(),
message: normalizedMessage ?? options?.message
});
this.emitRun(projectId, runDoc);
await writeRunFile(project.rootPath, runDoc);
await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "running",
lastMessage: normalizedMessage ?? options?.message ?? "Restart başlıyor..."
});
await this.emitStatus(projectId, {
lastStatus: "running",
lastMessage: normalizedMessage ?? options?.message ?? "Restart başlıyor..."
} as DeploymentProjectDocument);
try {
pushLog("Restart komutları çalıştırılıyor...");
await runCompose(project, (line) => pushLog(line));
const duration = Date.now() - startedAt;
await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "success",
lastDeployAt: new Date(),
lastMessage: normalizedMessage ?? options?.message ?? "Restart başarılı"
});
await this.emitStatus(projectId, {
lastStatus: "success",
lastDeployAt: new Date(),
lastMessage: normalizedMessage ?? options?.message ?? "Restart başarılı"
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "success",
finishedAt: new Date(),
durationMs: duration,
logs: runLogs,
message: normalizedMessage ?? options?.message
});
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
pushLog("Restart tamamlandı: Başarılı");
} catch (err) {
const duration = Date.now() - startedAt;
await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "failed",
lastDeployAt: new Date(),
lastMessage: (err as Error).message
});
await this.emitStatus(projectId, {
lastStatus: "failed",
lastDeployAt: new Date(),
lastMessage: (err as Error).message
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "failed",
finishedAt: new Date(),
durationMs: duration,
logs: runLogs,
message: normalizedMessage ?? options?.message
});
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
pushLog(`Hata: ${(err as Error).message}`);
} finally {
this.running.delete(projectId);
}
}
async cleanupProjectResources(project: DeploymentProjectDocument) {
const composePath = path.join(project.rootPath, project.composeFile);
if (!fs.existsSync(composePath)) {
return;
}
await runCommand(
`docker compose -f ${project.composeFile} down --remove-orphans -v --rmi local`,
project.rootPath,
() => undefined
);
}
async findByWebhookToken(token: string) { async findByWebhookToken(token: string) {
return DeploymentProject.findOne({ webhookToken: token }); return DeploymentProject.findOne({ webhookToken: token });
} }
async normalizeExistingCommitMessages() {
const projects = await DeploymentProject.find({
lastMessage: { $regex: /[\r\n]/ }
});
for (const project of projects) {
const normalized = normalizeCommitMessage(project.lastMessage);
if (normalized && normalized !== project.lastMessage) {
project.lastMessage = normalized;
await project.save();
}
}
const runs = await DeploymentRun.find({
message: { $regex: /[\r\n]/ }
});
for (const run of runs) {
const normalized = normalizeCommitMessage(run.message);
if (normalized && normalized !== run.message) {
run.message = normalized;
await run.save();
}
}
}
async bootstrapFromFilesystem() {
const candidateRoots = [
deploymentsRoot,
path.resolve(process.cwd(), "deployments"),
path.resolve(process.cwd(), "..", "deployments"),
path.resolve(process.cwd(), "..", "..", "deployments"),
"/root/Wisecolt-CI/deployments"
];
const roots = Array.from(
new Set(candidateRoots.filter((root) => root && fs.existsSync(root)))
);
for (const root of roots) {
const entries = await fs.promises.readdir(root, { withFileTypes: true });
const dirs = entries.filter((entry) => entry.isDirectory());
for (const entry of dirs) {
const rootPath = path.join(root, entry.name);
const existing = await DeploymentProject.findOne({ rootPath });
if (existing) continue;
const metadata = await readMetadata(rootPath);
const repoUrlRaw = metadata?.repoUrl || (await inferRepoUrlFromGit(rootPath));
if (!repoUrlRaw) continue;
const repoUrl = normalizeRepoUrl(repoUrlRaw);
const repoExisting = await DeploymentProject.findOne({ repoUrl });
if (repoExisting) continue;
const composeFile = metadata?.composeFile || inferComposeFile(rootPath);
if (!composeFile) continue;
const branch = metadata?.branch || (await inferBranchFromGit(rootPath)) || "main";
const name = metadata?.name || inferName(repoUrl, rootPath);
let webhookToken = metadata?.webhookToken || generateWebhookToken();
while (await DeploymentProject.findOne({ webhookToken })) {
webhookToken = generateWebhookToken();
}
let envContent = metadata?.envContent;
const envPath = path.join(rootPath, ".env");
if (!envContent && fs.existsSync(envPath)) {
envContent = await fs.promises.readFile(envPath, "utf8");
}
const envExampleName = metadata?.envExampleName;
const env = deriveEnv(composeFile);
const created = await DeploymentProject.create({
name,
rootPath,
repoUrl,
branch,
composeFile,
webhookToken,
env,
port: metadata?.port,
envContent,
envExampleName
});
await writeMetadata(rootPath, {
name: created.name,
repoUrl: created.repoUrl,
branch: created.branch,
composeFile: created.composeFile,
webhookToken: created.webhookToken,
env: created.env,
port: created.port,
envContent: created.envContent,
envExampleName: created.envExampleName
});
const storedRuns = await readStoredRuns(rootPath);
if (storedRuns.length > 0) {
storedRuns.sort(
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
await DeploymentRun.insertMany(
storedRuns.map((run) => ({
project: created._id,
status: run.status,
message: run.message,
logs: run.logs || [],
startedAt: new Date(run.startedAt),
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
durationMs: run.durationMs,
createdAt: new Date(run.createdAt),
updatedAt: new Date(run.updatedAt)
}))
);
const latest = storedRuns[0];
await DeploymentProject.findByIdAndUpdate(created._id, {
lastStatus: latest.status,
lastDeployAt: new Date(latest.finishedAt || latest.startedAt),
lastMessage: latest.message
});
}
}
}
}
} }
export const deploymentService = new DeploymentService(); export const deploymentService = new DeploymentService();

View File

@@ -3,9 +3,11 @@ import path from "path";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { Server } from "socket.io"; import { Server } from "socket.io";
import { Job, JobDocument, TimeUnit } from "../models/job.js"; import { Job, JobDocument, TimeUnit } from "../models/job.js";
import { JobRun } from "../models/jobRun.js"; import { JobRun, JobRunDocument } from "../models/jobRun.js";
const repoBaseDir = path.join(process.cwd(), "test-runs"); const repoBaseDir = path.join(process.cwd(), "test-runs");
const jobMetadataFileName = ".wisecolt-ci-job.json";
const jobRunsDirName = ".wisecolt-ci-job-runs";
function unitToMs(unit: TimeUnit) { function unitToMs(unit: TimeUnit) {
if (unit === "dakika") return 60_000; if (unit === "dakika") return 60_000;
@@ -17,6 +19,91 @@ function ensureDir(dir: string) {
return fs.promises.mkdir(dir, { recursive: true }); return fs.promises.mkdir(dir, { recursive: true });
} }
type JobMetadata = {
name: string;
repoUrl: string;
testCommand: string;
checkValue: number;
checkUnit: TimeUnit;
};
type StoredJobRun = {
status: "running" | "success" | "failed";
logs: string[];
startedAt: string;
finishedAt?: string;
durationMs?: number;
createdAt: string;
updatedAt: string;
};
function getJobDir(jobId: string) {
return path.join(repoBaseDir, jobId);
}
function getJobRunsDir(jobDir: string) {
return path.join(jobDir, jobRunsDirName);
}
async function readJobMetadata(jobDir: string): Promise<JobMetadata | null> {
const filePath = path.join(jobDir, jobMetadataFileName);
if (!fs.existsSync(filePath)) return null;
try {
const raw = await fs.promises.readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as JobMetadata;
if (!parsed?.repoUrl || !parsed?.testCommand) return null;
return parsed;
} catch {
return null;
}
}
async function writeJobMetadata(jobDir: string, data: JobMetadata) {
await ensureDir(jobDir);
const filePath = path.join(jobDir, jobMetadataFileName);
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
}
function serializeJobRun(run: JobRunDocument) {
return {
status: run.status,
logs: run.logs || [],
startedAt: new Date(run.startedAt).toISOString(),
finishedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : undefined,
durationMs: run.durationMs,
createdAt: new Date(run.createdAt).toISOString(),
updatedAt: new Date(run.updatedAt).toISOString()
} satisfies StoredJobRun;
}
async function writeJobRunFile(jobDir: string, run: JobRunDocument) {
const dir = getJobRunsDir(jobDir);
await ensureDir(dir);
const data = serializeJobRun(run);
const name = `${new Date(data.startedAt).getTime()}-${run._id.toString()}.json`;
const filePath = path.join(dir, name);
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
}
async function readStoredJobRuns(jobDir: string): Promise<StoredJobRun[]> {
const dir = getJobRunsDir(jobDir);
if (!fs.existsSync(dir)) return [];
const entries = await fs.promises.readdir(dir);
const items: StoredJobRun[] = [];
for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
try {
const raw = await fs.promises.readFile(path.join(dir, entry), "utf8");
const parsed = JSON.parse(raw) as StoredJobRun;
if (!parsed?.startedAt || !parsed?.status) continue;
items.push(parsed);
} catch {
// ignore invalid file
}
}
return items;
}
function cleanOutput(input: string) { function cleanOutput(input: string) {
// ANSI escape sequences temizleme // ANSI escape sequences temizleme
return input.replace( return input.replace(
@@ -85,8 +172,42 @@ async function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) {
const exists = fs.existsSync(gitDir); const exists = fs.existsSync(gitDir);
if (!exists) { if (!exists) {
const entries = await fs.promises.readdir(repoDir);
const allowed = new Set<string>([jobMetadataFileName, jobRunsDirName]);
const blocking = entries.filter((name) => !allowed.has(name));
if (blocking.length > 0) {
throw new Error("Repo klasoru git olmayan dosyalar iceriyor");
}
let metadataBackup: string | null = null;
const metadataPath = path.join(repoDir, jobMetadataFileName);
if (fs.existsSync(metadataPath)) {
metadataBackup = await fs.promises.readFile(metadataPath, "utf8");
}
let runsBackupPath: string | null = null;
const runsDir = path.join(repoDir, jobRunsDirName);
if (fs.existsSync(runsDir)) {
const tmpBase = await fs.promises.mkdtemp(path.join(repoBaseDir, ".tmp-"));
runsBackupPath = path.join(tmpBase, jobRunsDirName);
await fs.promises.rename(runsDir, runsBackupPath);
}
await Promise.all(
entries
.filter((name) => allowed.has(name))
.map((name) => fs.promises.rm(path.join(repoDir, name), { recursive: true, force: true }))
);
onData(`Repo klonlanıyor: ${job.repoUrl}`); onData(`Repo klonlanıyor: ${job.repoUrl}`);
await runCommand(`git clone ${job.repoUrl} ${repoDir}`, process.cwd(), onData); await runCommand(`git clone ${job.repoUrl} ${repoDir}`, process.cwd(), onData);
if (metadataBackup) {
await fs.promises.writeFile(metadataPath, metadataBackup, "utf8");
}
if (runsBackupPath) {
await fs.promises.rename(runsBackupPath, runsDir);
}
} else { } else {
onData("Repo güncelleniyor (git pull)..."); onData("Repo güncelleniyor (git pull)...");
await runCommand("git pull", repoDir, onData); await runCommand("git pull", repoDir, onData);
@@ -156,6 +277,7 @@ class JobService {
status: "running", status: "running",
startedAt: new Date() startedAt: new Date()
}); });
await writeJobRunFile(getJobDir(jobId), runDoc);
await Job.findByIdAndUpdate(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." }); await Job.findByIdAndUpdate(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." });
await this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument); await this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument);
@@ -179,6 +301,8 @@ class JobService {
durationMs: duration, durationMs: duration,
logs: runLogs logs: runLogs
}); });
const updatedRun = await JobRun.findById(runDoc._id);
if (updatedRun) await writeJobRunFile(getJobDir(jobId), updatedRun);
await this.emitStatus(jobId, { await this.emitStatus(jobId, {
status: "success", status: "success",
lastRunAt: new Date(), lastRunAt: new Date(),
@@ -199,6 +323,8 @@ class JobService {
durationMs: duration, durationMs: duration,
logs: runLogs logs: runLogs
}); });
const updatedRun = await JobRun.findById(runDoc._id);
if (updatedRun) await writeJobRunFile(getJobDir(jobId), updatedRun);
pushLog(`Hata: ${(err as Error).message}`); pushLog(`Hata: ${(err as Error).message}`);
await this.emitStatus(jobId, { await this.emitStatus(jobId, {
status: "failed", status: "failed",
@@ -231,6 +357,78 @@ class JobService {
const jobs = await Job.find(); const jobs = await Job.find();
jobs.forEach((job) => this.scheduleJob(job)); jobs.forEach((job) => this.scheduleJob(job));
} }
async persistMetadata(job: JobDocument) {
await writeJobMetadata(getJobDir(job._id.toString()), {
name: job.name,
repoUrl: job.repoUrl,
testCommand: job.testCommand,
checkValue: job.checkValue,
checkUnit: job.checkUnit
});
}
async bootstrapFromFilesystem() {
const candidateRoots = [
repoBaseDir,
path.resolve(process.cwd(), "test-runs"),
path.resolve(process.cwd(), "..", "test-runs"),
path.resolve(process.cwd(), "..", "..", "test-runs"),
"/root/Wisecolt-CI/test-runs"
];
const roots = Array.from(
new Set(candidateRoots.filter((root) => root && fs.existsSync(root)))
);
for (const root of roots) {
const entries = await fs.promises.readdir(root, { withFileTypes: true });
const dirs = entries.filter((entry) => entry.isDirectory());
for (const entry of dirs) {
const jobDir = path.join(root, entry.name);
const metadata = await readJobMetadata(jobDir);
if (!metadata) continue;
const existing = await Job.findOne({ repoUrl: metadata.repoUrl });
if (existing) continue;
const created = await Job.create({
name: metadata.name,
repoUrl: metadata.repoUrl,
testCommand: metadata.testCommand,
checkValue: metadata.checkValue,
checkUnit: metadata.checkUnit
});
await this.persistMetadata(created);
const storedRuns = await readStoredJobRuns(jobDir);
if (storedRuns.length > 0) {
storedRuns.sort(
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
await JobRun.insertMany(
storedRuns.map((run) => ({
job: created._id,
status: run.status,
logs: run.logs || [],
startedAt: new Date(run.startedAt),
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
durationMs: run.durationMs,
createdAt: new Date(run.createdAt),
updatedAt: new Date(run.updatedAt)
}))
);
const latest = storedRuns[0];
await Job.findByIdAndUpdate(created._id, {
status: latest.status === "running" ? "idle" : latest.status,
lastRunAt: new Date(latest.finishedAt || latest.startedAt),
lastDurationMs: latest.durationMs,
lastMessage: latest.status === "success" ? "Başarılı" : "Hata"
});
}
}
}
}
} }
export const jobService = new JobService(); export const jobService = new JobService();

View File

@@ -13,10 +13,12 @@ services:
volumes: volumes:
- ./backend:/app - ./backend:/app
- /app/node_modules - /app/node_modules
- ${DEPLOYMENTS_ROOT_HOST}:/workspace - ${PWD}:${PWD}
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
env_file: env_file:
- ./backend/.env - ./.env
environment:
DEPLOYMENTS_ROOT: ${PWD}/deployments
ports: ports:
- "4000:4000" - "4000:4000"
depends_on: depends_on:
@@ -29,7 +31,7 @@ services:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
env_file: env_file:
- ./frontend/.env - ./.env
ports: ports:
- "5173:5173" - "5173:5173"
depends_on: depends_on:

View File

@@ -3,10 +3,12 @@ services:
build: ./backend build: ./backend
command: npm run build && npm start command: npm run build && npm start
volumes: volumes:
- ${DEPLOYMENTS_ROOT_HOST}:/workspace - ${PWD}:${PWD}
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
env_file: env_file:
- ./backend/.env - ./.env
environment:
DEPLOYMENTS_ROOT: ${PWD}/deployments
ports: ports:
- "4000:4000" - "4000:4000"
@@ -17,7 +19,7 @@ services:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
env_file: env_file:
- ./frontend/.env - ./.env
environment: environment:
ALLOWED_HOSTS: ${ALLOWED_HOSTS} ALLOWED_HOSTS: ${ALLOWED_HOSTS}
ports: ports:

View File

@@ -1,3 +0,0 @@
VITE_API_URL=http://localhost:4000/api
# Prod için izin verilecek host(lar), virgülle ayırabilirsiniz. Örn:
# ALLOWED_HOSTS=wisecolt-ci-frontend-ft2pzo-1c0eb3-188-245-185-248.traefik.me

View File

@@ -16,6 +16,8 @@
"@fortawesome/react-fontawesome": "^3.1.0", "@fortawesome/react-fontawesome": "^3.1.0",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-scroll-area": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.3",
"axios": "^1.5.1", "axios": "^1.5.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",

View File

@@ -14,6 +14,8 @@ export interface DeploymentProject {
webhookToken: string; webhookToken: string;
env: DeploymentEnv; env: DeploymentEnv;
port?: number; port?: number;
envContent?: string;
envExampleName?: string;
lastDeployAt?: string; lastDeployAt?: string;
lastStatus: DeploymentStatus; lastStatus: DeploymentStatus;
lastMessage?: string; lastMessage?: string;
@@ -54,19 +56,14 @@ export interface DeploymentMetrics {
recentRuns: DeploymentRunWithProject[]; recentRuns: DeploymentRunWithProject[];
} }
export interface DeploymentCandidate {
name: string;
rootPath: string;
composeFiles: ComposeFile[];
}
export interface DeploymentInput { export interface DeploymentInput {
name: string; name: string;
rootPath: string;
repoUrl: string; repoUrl: string;
branch: string; branch: string;
composeFile: ComposeFile; composeFile: ComposeFile;
port?: number; port?: number;
envContent?: string;
envExampleName?: string;
} }
export async function fetchDeployments(): Promise<DeploymentProject[]> { export async function fetchDeployments(): Promise<DeploymentProject[]> {
@@ -81,17 +78,12 @@ export async function fetchDeploymentBranches(repoUrl: string): Promise<string[]
return (data as { branches: string[] }).branches; return (data as { branches: string[] }).branches;
} }
export async function scanDeployments(): Promise<DeploymentCandidate[]> {
const { data } = await apiClient.get("/deployments/scan");
return data as DeploymentCandidate[];
}
export async function createDeployment(payload: DeploymentInput): Promise<DeploymentProject> { export async function createDeployment(payload: DeploymentInput): Promise<DeploymentProject> {
const { data } = await apiClient.post("/deployments", payload); const { data } = await apiClient.post("/deployments", payload);
return data as DeploymentProject; return data as DeploymentProject;
} }
export async function updateDeployment(id: string, payload: Omit<DeploymentInput, "rootPath">) { export async function updateDeployment(id: string, payload: DeploymentInput) {
const { data } = await apiClient.put(`/deployments/${id}`, payload); const { data } = await apiClient.put(`/deployments/${id}`, payload);
return data as DeploymentProject; return data as DeploymentProject;
} }
@@ -100,8 +92,12 @@ export async function deleteDeployment(id: string): Promise<void> {
await apiClient.delete(`/deployments/${id}`); await apiClient.delete(`/deployments/${id}`);
} }
export async function runDeployment(id: string): Promise<void> { export async function runDeployment(id: string, message?: string): Promise<void> {
await apiClient.post(`/deployments/${id}/run`); await apiClient.post(`/deployments/${id}/run`, message ? { message } : {});
}
export async function restartDeployment(id: string, message?: string): Promise<void> {
await apiClient.post(`/deployments/${id}/restart`, message ? { message } : {});
} }
export async function fetchDeployment(id: string): Promise<DeploymentDetailResponse> { export async function fetchDeployment(id: string): Promise<DeploymentDetailResponse> {
@@ -113,3 +109,23 @@ export async function fetchDeploymentMetrics(): Promise<DeploymentMetrics> {
const { data } = await apiClient.get("/deployments/metrics/summary"); const { data } = await apiClient.get("/deployments/metrics/summary");
return data as DeploymentMetrics; return data as DeploymentMetrics;
} }
export async function fetchDeploymentComposeFiles(
repoUrl: string,
branch: string
): Promise<ComposeFile[]> {
const { data } = await apiClient.get("/deployments/compose-files", {
params: { repoUrl, branch }
});
return (data as { files: ComposeFile[] }).files;
}
export async function fetchDeploymentEnvExamples(
repoUrl: string,
branch: string
): Promise<Array<{ name: string; content: string }>> {
const { data } = await apiClient.get("/deployments/env-examples", {
params: { repoUrl, branch }
});
return (data as { examples: Array<{ name: string; content: string }> }).examples;
}

View File

@@ -3,6 +3,8 @@ import { apiClient } from "./client";
export interface SettingsResponse { export interface SettingsResponse {
webhookToken: string; webhookToken: string;
webhookSecret: string; webhookSecret: string;
cleanupIntervalValue?: number;
cleanupIntervalUnit?: "saat" | "gün" | "hafta";
updatedAt: string; updatedAt: string;
} }
@@ -20,3 +22,13 @@ export async function rotateWebhookSecret(): Promise<SettingsResponse> {
const { data } = await apiClient.post("/settings/secret/rotate"); const { data } = await apiClient.post("/settings/secret/rotate");
return data as SettingsResponse; return data as SettingsResponse;
} }
export async function saveCleanupInterval(value: number, unit: "saat" | "gün" | "hafta") {
const { data } = await apiClient.post("/settings/cleanup-interval", { value, unit });
return data as SettingsResponse;
}
export async function cleanupImages(): Promise<{ success: boolean }> {
const { data } = await apiClient.post("/settings/cleanup-images");
return data as { success: boolean };
}

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

@@ -0,0 +1,24 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "../../lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.Scrollbar
orientation="vertical"
className="flex touch-none select-none p-0.5 transition-colors"
>
<ScrollAreaPrimitive.Thumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.Scrollbar>
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
export { ScrollArea };

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "../../lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn("mt-4 ring-offset-background focus-visible:outline-none", className)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,12 +1,48 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft, faCloudArrowUp, faCopy, faHistory } from "@fortawesome/free-solid-svg-icons"; import {
faArrowLeft,
faCloudArrowUp,
faCopy,
faEye,
faEyeSlash,
faHistory,
faRotate
} from "@fortawesome/free-solid-svg-icons";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { JobStatusBadge } from "../components/JobStatusBadge"; import { JobStatusBadge } from "../components/JobStatusBadge";
import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments"; import { Label } from "../components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import {
DeploymentInput,
DeploymentProject,
DeploymentRun,
fetchDeployment,
fetchDeploymentBranches,
fetchDeploymentComposeFiles,
fetchDeploymentEnvExamples,
restartDeployment,
runDeployment,
updateDeployment
} from "../api/deployments";
import { useDeploymentStream } from "../providers/live-provider";
import { useSocket } from "../providers/socket-provider";
type FormState = {
_id?: string;
name: string;
repoUrl: string;
branch: string;
composeFile: DeploymentInput["composeFile"];
port: string;
};
type EnvExample = { name: string; content: string };
export function DeploymentDetailPage() { export function DeploymentDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -15,6 +51,29 @@ export function DeploymentDetailPage() {
const [runs, setRuns] = useState<DeploymentRun[]>([]); const [runs, setRuns] = useState<DeploymentRun[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [triggering, setTriggering] = useState(false); const [triggering, setTriggering] = useState(false);
const [restarting, setRestarting] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>({
name: "",
repoUrl: "",
branch: "main",
composeFile: "docker-compose.yml",
port: ""
});
const [branchOptions, setBranchOptions] = useState<string[]>([]);
const [branchLoading, setBranchLoading] = useState(false);
const [composeOptions, setComposeOptions] = useState<DeploymentInput["composeFile"][]>([]);
const [composeLoading, setComposeLoading] = useState(false);
const [envExamples, setEnvExamples] = useState<EnvExample[]>([]);
const [envLoading, setEnvLoading] = useState(false);
const [envContent, setEnvContent] = useState("");
const [envExampleName, setEnvExampleName] = useState("");
const [showEnv, setShowEnv] = useState(false);
const [activeTab, setActiveTab] = useState("details");
const stream = useDeploymentStream(id || "");
const socket = useSocket();
const isEdit = useMemo(() => !!form._id, [form._id]);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -27,12 +86,36 @@ export function DeploymentDetailPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
useEffect(() => {
if (!socket || !id) return;
socket.emit("deployment:subscribe", { deploymentId: id });
const handleRunUpdate = ({ deploymentId, run }: { deploymentId: string; run: DeploymentRun }) => {
if (deploymentId !== id) return;
setRuns((prev) => {
const existingIndex = prev.findIndex((item) => item._id === run._id);
if (existingIndex >= 0) {
const next = [...prev];
next[existingIndex] = { ...next[existingIndex], ...run };
return next;
}
return [run, ...prev];
});
};
socket.on("deployment:run", handleRunUpdate);
return () => {
socket.emit("deployment:unsubscribe", { deploymentId: id });
socket.off("deployment:run", handleRunUpdate);
};
}, [socket, id]);
const webhookUrl = useMemo(() => { const webhookUrl = useMemo(() => {
if (!project) return ""; if (!project) return "";
return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`; return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`;
}, [project]); }, [project]);
const latestRun = runs[0]; const latestRun = runs[0];
const effectiveStatus = stream.status || project?.lastStatus || latestRun?.status || "idle";
const currentLogs = stream.logs.length > 0 ? stream.logs : latestRun?.logs || [];
const decorateLogLine = (line: string) => { const decorateLogLine = (line: string) => {
const lower = line.toLowerCase(); const lower = line.toLowerCase();
@@ -56,7 +139,20 @@ export function DeploymentDetailPage() {
const handleCopy = async () => { const handleCopy = async () => {
try { try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(webhookUrl); await navigator.clipboard.writeText(webhookUrl);
} else {
const textarea = document.createElement("textarea");
textarea.value = webhookUrl;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
if (!ok) throw new Error("copy failed");
}
toast.success("Webhook URL kopyalandı"); toast.success("Webhook URL kopyalandı");
} catch { } catch {
toast.error("Webhook URL kopyalanamadı"); toast.error("Webhook URL kopyalanamadı");
@@ -76,6 +172,155 @@ export function DeploymentDetailPage() {
} }
}; };
const handleRestart = async () => {
if (!id) return;
setRestarting(true);
try {
await restartDeployment(id, "restart");
toast.success("Restart tetiklendi");
} catch {
toast.error("Restart tetiklenemedi");
} finally {
setRestarting(false);
}
};
useEffect(() => {
const repoUrl = form.repoUrl.trim();
if (!repoUrl) {
setBranchOptions([]);
setComposeOptions([]);
return;
}
const timer = setTimeout(async () => {
setBranchLoading(true);
try {
const branches = await fetchDeploymentBranches(repoUrl);
setBranchOptions(branches);
if (!form.branch && branches.length > 0) {
setForm((prev) => ({ ...prev, branch: branches.includes("main") ? "main" : branches[0] }));
}
} catch {
setBranchOptions([]);
} finally {
setBranchLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [form.repoUrl, form.branch]);
useEffect(() => {
const repoUrl = form.repoUrl.trim();
const branch = form.branch.trim();
if (!repoUrl || !branch) {
setEnvExamples([]);
setEnvExampleName("");
setComposeOptions([]);
return;
}
const timer = setTimeout(async () => {
setComposeLoading(true);
try {
const files = await fetchDeploymentComposeFiles(repoUrl, branch);
setComposeOptions(files);
if (files.length > 0 && !files.includes(form.composeFile)) {
setForm((prev) => ({ ...prev, composeFile: files[0] }));
}
} catch {
setComposeOptions([]);
} finally {
setComposeLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [form.repoUrl, form.branch, form.composeFile]);
useEffect(() => {
const repoUrl = form.repoUrl.trim();
const branch = form.branch.trim();
if (!repoUrl || !branch) {
return;
}
const timer = setTimeout(async () => {
setEnvLoading(true);
try {
const examples = await fetchDeploymentEnvExamples(repoUrl, branch);
setEnvExamples(examples);
if (examples.length === 0) {
return;
}
const selected = examples.find((example) => example.name === envExampleName) || examples[0];
if (!isEdit || !envContent) {
setEnvExampleName(selected.name);
setEnvContent(selected.content);
}
} catch {
setEnvExamples([]);
} finally {
setEnvLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [form.repoUrl, form.branch, envExampleName, isEdit, envContent]);
const handleEdit = () => {
if (!project) return;
const { _id, name, repoUrl, branch, composeFile, port } = project;
setForm({
_id,
name,
repoUrl,
branch,
composeFile,
port: port ? String(port) : ""
});
setEnvContent(project.envContent || "");
setEnvExampleName(project.envExampleName || "");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true);
};
const handleClose = () => {
setModalOpen(false);
};
const handleSave = async () => {
if (!form._id) return;
setSaving(true);
try {
const payload: DeploymentInput = {
name: form.name,
repoUrl: form.repoUrl,
branch: form.branch,
composeFile: form.composeFile,
port: form.port ? Number(form.port) : undefined,
envContent: envContent.trim() ? envContent : undefined,
envExampleName: envExampleName || undefined
};
if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) {
toast.error("Tüm alanları doldurun");
setSaving(false);
return;
}
const updated = await updateDeployment(form._id, payload);
setProject(updated);
try {
await runDeployment(updated._id, "update deploy");
} catch {
toast.error("Deploy tetiklenemedi");
}
toast.success("Deployment güncellendi");
setModalOpen(false);
} catch {
toast.error("İşlem sırasında hata oluştu");
} finally {
setSaving(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground"> <div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
@@ -93,6 +338,7 @@ export function DeploymentDetailPage() {
} }
return ( return (
<>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -107,10 +353,14 @@ export function DeploymentDetailPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => navigate("/deployments", { state: { editDeploymentId: project._id } })} onClick={handleEdit}
> >
Düzenle Düzenle
</Button> </Button>
<Button onClick={handleRestart} disabled={restarting} className="gap-2">
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
{restarting ? "Restarting..." : "Restart"}
</Button>
<Button onClick={handleRun} disabled={triggering} className="gap-2"> <Button onClick={handleRun} disabled={triggering} className="gap-2">
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" /> <FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
{triggering ? "Deploying..." : "Deploy"} {triggering ? "Deploying..." : "Deploy"}
@@ -121,7 +371,7 @@ export function DeploymentDetailPage() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Genel Bilgiler</CardTitle> <CardTitle>Genel Bilgiler</CardTitle>
<JobStatusBadge status={project.lastStatus} /> <JobStatusBadge status={effectiveStatus} />
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 text-sm text-muted-foreground"> <CardContent className="grid gap-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -172,10 +422,11 @@ export function DeploymentDetailPage() {
Deploy Geçmişi Deploy Geçmişi
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="max-h-[520px] overflow-y-auto pr-2">
{runs.length === 0 && ( {runs.length === 0 ? (
<div className="text-sm text-muted-foreground">Henüz deploy çalıştırılmadı.</div> <div className="text-sm text-muted-foreground">Henüz deploy çalıştırılmadı.</div>
)} ) : (
<div className="space-y-3">
{runs.map((run) => ( {runs.map((run) => (
<div <div
key={run._id} key={run._id}
@@ -195,6 +446,8 @@ export function DeploymentDetailPage() {
</div> </div>
</div> </div>
))} ))}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -204,8 +457,8 @@ export function DeploymentDetailPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="max-h-72 overflow-auto rounded-md border border-border bg-black px-3 py-2 font-mono text-xs text-green-100"> <div className="max-h-72 overflow-auto rounded-md border border-border bg-black px-3 py-2 font-mono text-xs text-green-100">
{latestRun?.logs?.length ? ( {currentLogs.length ? (
latestRun.logs.map((line, idx) => ( [...currentLogs].reverse().map((line, idx) => (
<div key={idx} className="whitespace-pre-wrap"> <div key={idx} className="whitespace-pre-wrap">
{decorateLogLine(line)} {decorateLogLine(line)}
</div> </div>
@@ -217,5 +470,230 @@ export function DeploymentDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
<div
className="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"
style={{ height: 620 }}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="space-y-1">
<div className="text-lg font-semibold text-foreground">Deployment Güncelle</div>
<div className="text-sm text-muted-foreground">
Repo ve branch seçimi sonrası webhook tetiklemeleriyle deploy yapılır.
</div>
</div>
<Button variant="ghost" size="icon" onClick={handleClose}>
</Button>
</div>
<div className="flex-1 overflow-hidden px-5 py-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="details">Genel</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
</TabsList>
<TabsContent value="details" className="h-[420px] space-y-4">
{!isEdit && (
<div className="h-[1.25rem] text-xs text-muted-foreground">
Repo URL girildiğinde branch ve compose dosyaları listelenir.
</div>
)}
<div className="space-y-2">
<Label htmlFor="repo">Repo URL</Label>
<Input
id="repo"
value={form.repoUrl}
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
placeholder="https://gitea.example.com/org/repo"
required
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Deployment Name</Label>
<Input
id="name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="wisecolt-app"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="branch">Branch</Label>
{branchOptions.length > 0 ? (
<Select
value={form.branch}
onValueChange={(value) => setForm((prev) => ({ ...prev, branch: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Branch seçin" />
</SelectTrigger>
<SelectContent>
{branchOptions.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="branch"
value={form.branch}
onChange={(e) => setForm((prev) => ({ ...prev, branch: e.target.value }))}
placeholder="main"
required
/>
)}
<div className="h-[1.25rem] text-xs text-muted-foreground">
{branchLoading
? "Branch listesi alınıyor..."
: branchOptions.length > 0
? "Repo üzerindeki branch'lar listelendi."
: "Repo URL girildiğinde branch listesi otomatik gelir."}
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Compose Dosyası</Label>
<Select
value={form.composeFile}
onValueChange={(value) =>
setForm((prev) => ({
...prev,
composeFile: value as DeploymentInput["composeFile"]
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Compose seçin" />
</SelectTrigger>
<SelectContent>
{(composeOptions.length > 0
? composeOptions
: ["docker-compose.yml", "docker-compose.dev.yml"]
).map((file) => (
<SelectItem key={file} value={file}>
{file}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="h-[1.25rem] text-xs text-muted-foreground">
{composeLoading
? "Compose dosyaları alınıyor..."
: composeOptions.length > 0
? "Repo üzerindeki compose dosyaları listelendi."
: "Repo URL ve branch sonrası compose dosyaları listelenir."}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="port">Port (opsiyonel)</Label>
<Input
id="port"
type="number"
min={1}
value={form.port}
onChange={(e) => setForm((prev) => ({ ...prev, port: e.target.value }))}
placeholder="3000"
/>
</div>
</div>
</TabsContent>
<TabsContent value="environment" className="h-[420px] space-y-4">
<div className="space-y-2">
<Label>.env.example</Label>
{envExamples.length > 0 ? (
<Select
value={envExampleName}
onValueChange={(value) => {
const example = envExamples.find((item) => item.name === value);
setEnvExampleName(value);
if (example) {
setEnvContent(example.content);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Env example seçin" />
</SelectTrigger>
<SelectContent>
{envExamples.map((example) => (
<SelectItem key={example.name} value={example.name}>
{example.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="h-[2.5rem] rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
{envLoading
? "Env example dosyaları alınıyor..."
: "Repo içinde .env.example bulunamadı."}
</div>
)}
<div className="h-[1.25rem] text-xs text-muted-foreground">
{envExamples.length > 0
? "Repo üzerindeki env example dosyaları listelendi."
: envLoading
? "Env example dosyaları alınıyor..."
: "Repo içinde .env.example bulunamadı."}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="env-content">Environment</Label>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowEnv((prev) => !prev)}
>
<FontAwesomeIcon icon={showEnv ? faEyeSlash : faEye} className="h-4 w-4" />
</Button>
</div>
<textarea
id="env-content"
value={envContent}
onChange={(e) => setEnvContent(e.target.value)}
className="h-[180px] w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm font-mono text-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={
showEnv ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties)
}
placeholder="ENV içerikleri burada listelenir."
/>
<div className="min-h-[1.25rem] text-xs text-muted-foreground">
Kaydedince içerik deployment kök dizinine{" "}
<span className="font-mono">.env</span> olarak yazılır.
</div>
</div>
</TabsContent>
</Tabs>
</div>
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
<Button variant="ghost" onClick={handleClose} disabled={saving}>
İptal
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Kaydediliyor..." : "Kaydet"}
</Button>
</div>
</div>
</div>
)}
</>
); );
} }

View File

@@ -1,9 +1,12 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
faCloudArrowUp, faCloudArrowUp,
faEye,
faEyeSlash,
faPenToSquare,
faPlus, faPlus,
faRotate, faRotate,
faRocket faRocket
@@ -13,33 +16,36 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label"; import { Label } from "../components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { import {
createDeployment, createDeployment,
deleteDeployment, deleteDeployment,
DeploymentCandidate,
DeploymentInput, DeploymentInput,
DeploymentProject, DeploymentProject,
fetchDeploymentComposeFiles,
fetchDeploymentBranches, fetchDeploymentBranches,
fetchDeploymentEnvExamples,
fetchDeployments, fetchDeployments,
restartDeployment,
runDeployment, runDeployment,
scanDeployments,
updateDeployment updateDeployment
} from "../api/deployments"; } from "../api/deployments";
import { JobStatusBadge } from "../components/JobStatusBadge"; import { JobStatusBadge } from "../components/JobStatusBadge";
import { useLiveData } from "../providers/live-provider";
type FormState = { type FormState = {
_id?: string; _id?: string;
name: string; name: string;
rootPath: string;
repoUrl: string; repoUrl: string;
branch: string; branch: string;
composeFile: DeploymentInput["composeFile"]; composeFile: DeploymentInput["composeFile"];
port: string; port: string;
}; };
type EnvExample = { name: string; content: string };
const defaultForm: FormState = { const defaultForm: FormState = {
name: "", name: "",
rootPath: "",
repoUrl: "", repoUrl: "",
branch: "main", branch: "main",
composeFile: "docker-compose.yml", composeFile: "docker-compose.yml",
@@ -49,24 +55,27 @@ const defaultForm: FormState = {
export function DeploymentsPage() { export function DeploymentsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { deploymentStreams } = useLiveData();
const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, ""); const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "");
const [deployments, setDeployments] = useState<DeploymentProject[]>([]); const [deployments, setDeployments] = useState<DeploymentProject[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [scanning, setScanning] = useState(false);
const [candidates, setCandidates] = useState<DeploymentCandidate[]>([]);
const [form, setForm] = useState<FormState>(defaultForm); const [form, setForm] = useState<FormState>(defaultForm);
const [pendingEditId, setPendingEditId] = useState<string | null>(null); const [pendingEditId, setPendingEditId] = useState<string | null>(null);
const [branchOptions, setBranchOptions] = useState<string[]>([]); const [branchOptions, setBranchOptions] = useState<string[]>([]);
const [branchLoading, setBranchLoading] = useState(false); const [branchLoading, setBranchLoading] = useState(false);
const [composeOptions, setComposeOptions] = useState<DeploymentInput["composeFile"][]>([]);
const [composeLoading, setComposeLoading] = useState(false);
const [envExamples, setEnvExamples] = useState<EnvExample[]>([]);
const [envLoading, setEnvLoading] = useState(false);
const [envContent, setEnvContent] = useState("");
const [envExampleName, setEnvExampleName] = useState("");
const [showEnv, setShowEnv] = useState(false);
const [activeTab, setActiveTab] = useState("details");
const [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({}); const [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({});
const isEdit = useMemo(() => !!form._id, [form._id]); const isEdit = useMemo(() => !!form._id, [form._id]);
const selectedCandidate = useMemo(
() => candidates.find((c) => c.rootPath === form.rootPath),
[candidates, form.rootPath]
);
const loadDeployments = async () => { const loadDeployments = async () => {
setLoading(true); setLoading(true);
@@ -80,18 +89,6 @@ export function DeploymentsPage() {
} }
}; };
const loadCandidates = async () => {
setScanning(true);
try {
const data = await scanDeployments();
setCandidates(data);
} catch {
toast.error("Root taraması yapılamadı");
} finally {
setScanning(false);
}
};
useEffect(() => { useEffect(() => {
loadDeployments(); loadDeployments();
}, []); }, []);
@@ -100,8 +97,17 @@ export function DeploymentsPage() {
const repoUrl = form.repoUrl.trim(); const repoUrl = form.repoUrl.trim();
if (!repoUrl) { if (!repoUrl) {
setBranchOptions([]); setBranchOptions([]);
setComposeOptions([]);
return; return;
} }
if (!form._id && !form.name) {
const normalized = repoUrl.replace(/\/+$/, "");
const lastPart = normalized.split("/").pop() || "";
const name = lastPart.replace(/\.git$/i, "");
if (name) {
setForm((prev) => ({ ...prev, name }));
}
}
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
setBranchLoading(true); setBranchLoading(true);
try { try {
@@ -119,6 +125,67 @@ export function DeploymentsPage() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [form.repoUrl, form.branch]); }, [form.repoUrl, form.branch]);
useEffect(() => {
const repoUrl = form.repoUrl.trim();
const branch = form.branch.trim();
if (!repoUrl || !branch) {
setEnvExamples([]);
setEnvExampleName("");
if (!isEdit) {
setEnvContent("");
}
setComposeOptions([]);
return;
}
const timer = setTimeout(async () => {
setComposeLoading(true);
try {
const files = await fetchDeploymentComposeFiles(repoUrl, branch);
setComposeOptions(files);
if (files.length > 0 && !files.includes(form.composeFile)) {
setForm((prev) => ({ ...prev, composeFile: files[0] }));
}
} catch {
setComposeOptions([]);
} finally {
setComposeLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [form.repoUrl, form.branch, form.composeFile]);
useEffect(() => {
const repoUrl = form.repoUrl.trim();
const branch = form.branch.trim();
if (!repoUrl || !branch) {
return;
}
const timer = setTimeout(async () => {
setEnvLoading(true);
try {
const examples = await fetchDeploymentEnvExamples(repoUrl, branch);
setEnvExamples(examples);
if (examples.length === 0) {
if (!isEdit) {
setEnvExampleName("");
setEnvContent("");
}
return;
}
const selected = examples.find((example) => example.name === envExampleName) || examples[0];
if (!isEdit || !envContent) {
setEnvExampleName(selected.name);
setEnvContent(selected.content);
}
} catch {
setEnvExamples([]);
} finally {
setEnvLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [form.repoUrl, form.branch, envExampleName, isEdit]);
useEffect(() => { useEffect(() => {
const state = location.state as { editDeploymentId?: string } | null; const state = location.state as { editDeploymentId?: string } | null;
if (state?.editDeploymentId) { if (state?.editDeploymentId) {
@@ -139,21 +206,29 @@ export function DeploymentsPage() {
const handleOpenNew = async () => { const handleOpenNew = async () => {
setForm(defaultForm); setForm(defaultForm);
setBranchOptions([]); setBranchOptions([]);
setComposeOptions([]);
setEnvExamples([]);
setEnvContent("");
setEnvExampleName("");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true); setModalOpen(true);
await loadCandidates();
}; };
const handleEdit = (deployment: DeploymentProject) => { const handleEdit = (deployment: DeploymentProject) => {
const { _id, name, rootPath, repoUrl, branch, composeFile, port } = deployment; const { _id, name, repoUrl, branch, composeFile, port } = deployment;
setForm({ setForm({
_id, _id,
name, name,
rootPath,
repoUrl, repoUrl,
branch, branch,
composeFile, composeFile,
port: port ? String(port) : "" port: port ? String(port) : ""
}); });
setEnvContent(deployment.envContent || "");
setEnvExampleName(deployment.envExampleName || "");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true); setModalOpen(true);
}; };
@@ -166,14 +241,15 @@ export function DeploymentsPage() {
try { try {
const payload: DeploymentInput = { const payload: DeploymentInput = {
name: form.name, name: form.name,
rootPath: form.rootPath,
repoUrl: form.repoUrl, repoUrl: form.repoUrl,
branch: form.branch, branch: form.branch,
composeFile: form.composeFile, composeFile: form.composeFile,
port: form.port ? Number(form.port) : undefined port: form.port ? Number(form.port) : undefined,
envContent: envContent.trim() ? envContent : undefined,
envExampleName: envExampleName || undefined
}; };
if (!payload.name || !payload.rootPath || !payload.repoUrl || !payload.branch || !payload.composeFile) { if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) {
toast.error("Tüm alanları doldurun"); toast.error("Tüm alanları doldurun");
setSaving(false); setSaving(false);
return; return;
@@ -185,9 +261,16 @@ export function DeploymentsPage() {
repoUrl: payload.repoUrl, repoUrl: payload.repoUrl,
branch: payload.branch, branch: payload.branch,
composeFile: payload.composeFile, composeFile: payload.composeFile,
port: payload.port port: payload.port,
envContent: payload.envContent,
envExampleName: payload.envExampleName
}); });
setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d))); setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d)));
try {
await runDeployment(updated._id, "update deploy");
} catch {
toast.error("Deploy tetiklenemedi");
}
toast.success("Deployment güncellendi"); toast.success("Deployment güncellendi");
} else { } else {
const created = await createDeployment(payload); const created = await createDeployment(payload);
@@ -212,6 +295,15 @@ export function DeploymentsPage() {
} }
}; };
const handleRestart = async (id: string) => {
try {
await restartDeployment(id, "restart");
toast.success("Restart tetiklendi");
} catch {
toast.error("Restart tetiklenemedi");
}
};
const handleDelete = async (deployment: DeploymentProject) => { const handleDelete = async (deployment: DeploymentProject) => {
const ok = window.confirm("Bu deployment'ı silmek istediğinize emin misiniz?"); const ok = window.confirm("Bu deployment'ı silmek istediğinize emin misiniz?");
if (!ok) return; if (!ok) return;
@@ -288,7 +380,9 @@ export function DeploymentsPage() {
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<JobStatusBadge status={deployment.lastStatus} /> <JobStatusBadge
status={deploymentStreams[deployment._id]?.status || deployment.lastStatus}
/>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80"> <span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
{deployment.env.toUpperCase()} {deployment.env.toUpperCase()}
</span> </span>
@@ -310,6 +404,18 @@ export function DeploymentsPage() {
> >
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" /> <FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
</Button> </Button>
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleRestart(deployment._id);
}}
title="Restart"
aria-label="Restart"
>
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
</Button>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
@@ -319,7 +425,7 @@ export function DeploymentsPage() {
}} }}
title="Düzenle" title="Düzenle"
> >
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" /> <FontAwesomeIcon icon={faPenToSquare} className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -357,7 +463,10 @@ export function DeploymentsPage() {
{modalOpen && ( {modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-border bg-card card-shadow"> <div
className="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"
style={{ height: 626 }}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4"> <div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="space-y-1"> <div className="space-y-1">
<div className="text-lg font-semibold text-foreground"> <div className="text-lg font-semibold text-foreground">
@@ -372,50 +481,17 @@ export function DeploymentsPage() {
</Button> </Button>
</div> </div>
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4"> <div className="flex-1 overflow-hidden px-5 py-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="details">Genel</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
</TabsList>
<TabsContent value="details" className="h-[420px] space-y-4">
{!isEdit && ( {!isEdit && (
<div className="space-y-2"> <div className="h-[1.25rem] text-xs text-muted-foreground">
<div className="flex items-center justify-between"> Repo URL girildiğinde branch ve compose dosyaları listelenir.
<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">
{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>
)} )}
@@ -468,7 +544,7 @@ export function DeploymentsPage() {
required required
/> />
)} )}
<div className="text-xs text-muted-foreground"> <div className="h-[1.25rem] text-xs text-muted-foreground">
{branchLoading {branchLoading
? "Branch listesi alınıyor..." ? "Branch listesi alınıyor..."
: branchOptions.length > 0 : branchOptions.length > 0
@@ -484,22 +560,33 @@ export function DeploymentsPage() {
<Select <Select
value={form.composeFile} value={form.composeFile}
onValueChange={(value) => onValueChange={(value) =>
setForm((prev) => ({ ...prev, composeFile: value as DeploymentInput["composeFile"] })) setForm((prev) => ({
...prev,
composeFile: value as DeploymentInput["composeFile"]
}))
} }
> >
<SelectTrigger> <SelectTrigger>
<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="h-[1.25rem] text-xs text-muted-foreground">
{composeLoading
? "Compose dosyaları alınıyor..."
: composeOptions.length > 0
? "Repo üzerindeki compose dosyaları listelendi."
: "Repo URL ve branch sonrası compose dosyaları listelenir."}
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -514,6 +601,77 @@ export function DeploymentsPage() {
/> />
</div> </div>
</div> </div>
</TabsContent>
<TabsContent value="environment" className="h-[420px] space-y-4">
<div className="space-y-2">
<Label>.env.example</Label>
{envExamples.length > 0 ? (
<Select
value={envExampleName}
onValueChange={(value) => {
const example = envExamples.find((item) => item.name === value);
setEnvExampleName(value);
if (example) {
setEnvContent(example.content);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Env example seçin" />
</SelectTrigger>
<SelectContent>
{envExamples.map((example) => (
<SelectItem key={example.name} value={example.name}>
{example.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="h-[2.5rem] rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
{envLoading
? "Env example dosyaları alınıyor..."
: "Repo içinde .env.example bulunamadı."}
</div>
)}
<div className="h-[1.25rem] text-xs text-muted-foreground">
{envExamples.length > 0
? "Repo üzerindeki env example dosyaları listelendi."
: envLoading
? "Env example dosyaları alınıyor..."
: "Repo içinde .env.example bulunamadı."}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="env-content">Environment</Label>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowEnv((prev) => !prev)}
>
<FontAwesomeIcon icon={showEnv ? faEyeSlash : faEye} className="h-4 w-4" />
</Button>
</div>
<textarea
id="env-content"
value={envContent}
onChange={(e) => setEnvContent(e.target.value)}
className="h-[180px] w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm font-mono text-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={
showEnv ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties)
}
placeholder="ENV içerikleri burada listelenir."
/>
<div className="min-h-[1.25rem] text-xs text-muted-foreground">
Kaydedince içerik deployment kök dizinine <span className="font-mono">.env</span> olarak yazılır.
</div>
</div>
</TabsContent>
</Tabs>
</div> </div>
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4"> <div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { import {
Line, Line,
LineChart, LineChart,
@@ -18,6 +18,7 @@ import { JobStatusBadge } from "../components/JobStatusBadge";
import { RepoIcon } from "../components/RepoIcon"; import { RepoIcon } from "../components/RepoIcon";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClockRotateLeft, faListCheck, faFlaskVial, faRocket } from "@fortawesome/free-solid-svg-icons"; import { faClockRotateLeft, faListCheck, faFlaskVial, faRocket } from "@fortawesome/free-solid-svg-icons";
import { useAuth } from "../providers/auth-provider";
function formatDuration(ms?: number) { function formatDuration(ms?: number) {
if (!ms || Number.isNaN(ms)) return "-"; if (!ms || Number.isNaN(ms)) return "-";
@@ -41,9 +42,14 @@ export function HomePage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { jobStreams } = useLiveData(); const { jobStreams } = useLiveData();
const { token } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
useEffect(() => { const loadMetrics = useCallback(() => {
if (!token) return;
setLoading(true);
setError(null);
Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()]) Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()])
.then(([jobResult, deployResult]) => { .then(([jobResult, deployResult]) => {
if (jobResult.status === "fulfilled") { if (jobResult.status === "fulfilled") {
@@ -54,7 +60,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") {
@@ -65,7 +71,25 @@ export function HomePage() {
} }
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, [token]);
useEffect(() => {
loadMetrics();
}, [loadMetrics, location.key]);
useEffect(() => {
const handleFocus = () => {
if (document.visibilityState === "visible") {
loadMetrics();
}
};
window.addEventListener("focus", handleFocus);
document.addEventListener("visibilitychange", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleFocus);
};
}, [loadMetrics]);
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (!metrics) { if (!metrics) {
@@ -150,7 +174,24 @@ export function HomePage() {
.slice(0, 10); .slice(0, 10);
}, [mergedRuns, deployRuns]); }, [mergedRuns, deployRuns]);
const lastRunDuration = useMemo(() => formatDuration(mergedRuns[0]?.durationMs), [mergedRuns]); const combinedTotals = useMemo(() => {
const jobSuccess = metrics?.dailyStats.reduce((acc, d) => acc + (d.success || 0), 0) ?? 0;
const jobTotal = metrics?.dailyStats.reduce((acc, d) => acc + (d.total || 0), 0) ?? 0;
const deploySuccess =
deploymentMetrics?.dailyStats.reduce((acc, d) => acc + (d.success || 0), 0) ?? 0;
const deployTotal =
deploymentMetrics?.dailyStats.reduce((acc, d) => acc + (d.total || 0), 0) ?? 0;
const totalRuns = jobTotal + deployTotal;
const successRate = totalRuns
? Math.round(((jobSuccess + deploySuccess) / totalRuns) * 100)
: 0;
return { totalRuns, successRate };
}, [metrics, deploymentMetrics]);
const lastRunDuration = useMemo(() => {
const latest = activityItems[0];
return formatDuration(latest?.durationMs);
}, [activityItems]);
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
@@ -163,7 +204,7 @@ export function HomePage() {
</div> </div>
<div className="text-xs text-muted-foreground flex items-center gap-2"> <div className="text-xs text-muted-foreground flex items-center gap-2">
<FontAwesomeIcon icon={faClockRotateLeft} className="h-3.5 w-3.5" /> <FontAwesomeIcon icon={faClockRotateLeft} className="h-3.5 w-3.5" />
{metrics?.totals.totalRuns ?? 0} toplam koşu {combinedTotals.totalRuns} toplam koşu
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="h-48 min-w-0"> <CardContent className="h-48 min-w-0">
@@ -210,13 +251,13 @@ export function HomePage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Başarı Oranı</span> <span>Başarı Oranı</span>
<span className="text-lg font-semibold text-foreground"> <span className="text-lg font-semibold text-foreground">
{metrics?.totals.successRate ?? 0}% {combinedTotals.successRate}%
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Toplam Çalıştırma</span> <span>Toplam Çalıştırma</span>
<span className="text-lg font-semibold text-foreground"> <span className="text-lg font-semibold text-foreground">
{metrics?.totals.totalRuns ?? 0} {combinedTotals.totalRuns}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -256,6 +297,7 @@ export function HomePage() {
<RepoIcon repoUrl={run.repoUrl} /> <RepoIcon repoUrl={run.repoUrl} />
<div> <div>
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground"> <div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
<span>{run.title}</span>
<span <span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${ className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${
run.type === "test" run.type === "test"
@@ -269,7 +311,6 @@ export function HomePage() {
/> />
{run.type === "test" ? "Test" : "Deploy"} {run.type === "test" ? "Test" : "Deploy"}
</span> </span>
<span>{run.title}</span>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{new Date(run.startedAt).toLocaleString()} · Süre:{" "} {new Date(run.startedAt).toLocaleString()} · Süre:{" "}

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}

View File

@@ -1,10 +1,20 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy, faEye, faEyeSlash, faRotate } from "@fortawesome/free-solid-svg-icons"; import { faBroom, faCopy, faEye, faEyeSlash, faFloppyDisk, faRotate } from "@fortawesome/free-solid-svg-icons";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { fetchSettings, rotateWebhookSecret, rotateWebhookToken, SettingsResponse } from "../api/settings"; import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
import {
cleanupImages,
fetchSettings,
rotateWebhookSecret,
rotateWebhookToken,
saveCleanupInterval,
SettingsResponse
} from "../api/settings";
export function SettingsPage() { export function SettingsPage() {
const [settings, setSettings] = useState<SettingsResponse | null>(null); const [settings, setSettings] = useState<SettingsResponse | null>(null);
@@ -13,17 +23,42 @@ export function SettingsPage() {
const [rotatingSecret, setRotatingSecret] = useState(false); const [rotatingSecret, setRotatingSecret] = useState(false);
const [showToken, setShowToken] = useState(false); const [showToken, setShowToken] = useState(false);
const [showSecret, setShowSecret] = useState(false); const [showSecret, setShowSecret] = useState(false);
const [cleanupValue, setCleanupValue] = useState<string>("1");
const [cleanupUnit, setCleanupUnit] = useState<"saat" | "gün" | "hafta">("hafta");
const [savingCleanup, setSavingCleanup] = useState(false);
const [cleaning, setCleaning] = useState(false);
useEffect(() => { useEffect(() => {
fetchSettings() fetchSettings()
.then((data) => setSettings(data)) .then((data) => {
setSettings(data);
if (data.cleanupIntervalValue) {
setCleanupValue(String(data.cleanupIntervalValue));
}
if (data.cleanupIntervalUnit) {
setCleanupUnit(data.cleanupIntervalUnit);
}
})
.catch(() => toast.error("Settings yüklenemedi")) .catch(() => toast.error("Settings yüklenemedi"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
const handleCopy = async (value: string, label: string) => { const handleCopy = async (value: string, label: string) => {
try { try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
} else {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
if (!ok) throw new Error("copy failed");
}
toast.success(`${label} kopyalandı`); toast.success(`${label} kopyalandı`);
} catch { } catch {
toast.error(`${label} kopyalanamadı`); toast.error(`${label} kopyalanamadı`);
@@ -64,6 +99,36 @@ export function SettingsPage() {
} }
}; };
const handleSaveCleanup = async () => {
const value = Number(cleanupValue);
if (!value || Number.isNaN(value) || value < 1) {
toast.error("Geçerli bir periyot girin");
return;
}
setSavingCleanup(true);
try {
const data = await saveCleanupInterval(value, cleanupUnit);
setSettings((prev) => (prev ? { ...prev, ...data } : data));
toast.success("Temizlik periyodu kaydedildi");
} catch {
toast.error("Periyot kaydedilemedi");
} finally {
setSavingCleanup(false);
}
};
const handleCleanupImages = async () => {
setCleaning(true);
try {
await cleanupImages();
toast.success("Kullanılmayan image'lar temizlendi");
} catch {
toast.error("Temizlik başarısız");
} finally {
setCleaning(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground"> <div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
@@ -160,6 +225,58 @@ export function SettingsPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Image Temizliği</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<div className="space-y-2">
<Label htmlFor="cleanupValue">Temizlik Periyodu</Label>
<div className="flex items-center gap-2">
<Input
id="cleanupValue"
type="number"
min="1"
value={cleanupValue}
onChange={(e) => setCleanupValue(e.target.value)}
className="bg-white"
/>
<Select value={cleanupUnit} onValueChange={(value) => setCleanupUnit(value as typeof cleanupUnit)}>
<SelectTrigger className="w-32">
<SelectValue placeholder="Birim" />
</SelectTrigger>
<SelectContent>
<SelectItem value="saat">saat</SelectItem>
<SelectItem value="gün">gün</SelectItem>
<SelectItem value="hafta">hafta</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<Button
variant="outline"
onClick={handleSaveCleanup}
disabled={savingCleanup}
className="gap-2 bg-white text-foreground hover:bg-muted"
>
<FontAwesomeIcon icon={faFloppyDisk} className="h-4 w-4" />
Kaydet
</Button>
<Button
onClick={handleCleanupImages}
disabled={cleaning}
className="gap-2 bg-black text-white hover:bg-black/90"
>
<FontAwesomeIcon icon={faBroom} className="h-4 w-4" />
Clean Cache Images
</Button>
</div>
</div>
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -23,7 +23,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setToken(stored); setToken(stored);
fetchMe() fetchMe()
.then((data) => setUser({ username: data.username })) .then((data) => setUser({ username: data.username }))
.catch(() => setAuthToken(undefined)) .catch(() => {
setAuthToken(undefined);
setToken(null);
setUser(null);
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
} else { } else {
setLoading(false); setLoading(false);

View File

@@ -12,6 +12,7 @@ type JobStream = {
type LiveContextValue = { type LiveContextValue = {
jobStreams: Record<string, JobStream>; jobStreams: Record<string, JobStream>;
deploymentStreams: Record<string, JobStream>;
}; };
const LiveContext = createContext<LiveContextValue | undefined>(undefined); const LiveContext = createContext<LiveContextValue | undefined>(undefined);
@@ -19,6 +20,7 @@ const LiveContext = createContext<LiveContextValue | undefined>(undefined);
export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const socket = useSocket(); const socket = useSocket();
const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({}); const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({});
const [deploymentStreams, setDeploymentStreams] = useState<Record<string, JobStream>>({});
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
@@ -54,20 +56,59 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children
}); });
}; };
const handleDeploymentLog = ({ deploymentId, line }: { deploymentId: string; line: string }) => {
if (!deploymentId) return;
setDeploymentStreams((prev) => {
const current = prev[deploymentId] || { logs: [] };
const nextLogs = [...current.logs, line].slice(-200);
return { ...prev, [deploymentId]: { ...current, logs: nextLogs } };
});
};
const handleDeploymentStatus = ({
deploymentId,
status,
lastRunAt,
lastMessage,
runCount,
lastDurationMs
}: {
deploymentId: string;
status?: string;
lastRunAt?: string;
lastMessage?: string;
runCount?: number;
lastDurationMs?: number;
}) => {
if (!deploymentId) return;
setDeploymentStreams((prev) => {
const current = prev[deploymentId] || { logs: [] };
return {
...prev,
[deploymentId]: { ...current, status, lastRunAt, lastMessage, runCount, lastDurationMs }
};
});
};
socket.on("job:log", handleJobLog); socket.on("job:log", handleJobLog);
socket.on("job:status", handleJobStatus); socket.on("job:status", handleJobStatus);
socket.on("deployment:log", handleDeploymentLog);
socket.on("deployment:status", handleDeploymentStatus);
return () => { return () => {
socket.off("job:log", handleJobLog); socket.off("job:log", handleJobLog);
socket.off("job:status", handleJobStatus); socket.off("job:status", handleJobStatus);
socket.off("deployment:log", handleDeploymentLog);
socket.off("deployment:status", handleDeploymentStatus);
}; };
}, [socket]); }, [socket]);
const value = useMemo( const value = useMemo(
() => ({ () => ({
jobStreams jobStreams,
deploymentStreams
}), }),
[jobStreams] [jobStreams, deploymentStreams]
); );
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>; return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
@@ -87,3 +128,12 @@ export function useJobStream(jobId: string) {
[ctx.jobStreams, jobId] [ctx.jobStreams, jobId]
); );
} }
export function useDeploymentStream(deploymentId: string) {
const ctx = useContext(LiveContext);
if (!ctx) throw new Error("useDeploymentStream LiveProvider içinde kullanılmalı");
return useMemo(
() => ctx.deploymentStreams[deploymentId] || { logs: [], status: "idle", runCount: 0 },
[ctx.deploymentStreams, deploymentId]
);
}

View File

@@ -10,7 +10,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const socketRef = useRef<Socket | null>(null); const socketRef = useRef<Socket | null>(null);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const baseUrl = useMemo(() => apiClient.defaults.baseURL || window.location.origin, []); const baseUrl = useMemo(() => window.location.origin, []);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -22,6 +22,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const socket = io(baseUrl, { const socket = io(baseUrl, {
auth: { token }, auth: { token },
path: "/api/socket.io",
transports: ["websocket", "polling"] transports: ["websocket", "polling"]
}); });