Compare commits

..

25 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
2b053120cb Projeleri otomatik deployment etme özelliği eklendi.
Reviewed-on: #1
2026-01-18 13:40:52 +00:00
dc8d0eef1b docs: deployment özelliklerini dokümantasyona ekle 2026-01-18 16:29:47 +03:00
e5fd3bd9d5 feat(deployments): docker tabanlı proje yönetim ve otomatik deploy sistemi ekle
Docker Compose projeleri için tam kapsamlı yönetim paneli ve otomatik deployment altyapısı eklendi.

Sistem özellikleri:
- Belirtilen root dizin altındaki docker-compose dosyası içeren projeleri tarama
- Git repo bağlantısı ile branch yönetimi ve klonlama/pull işlemleri
- Docker compose up/down komutları ile otomatik deploy
- Gitea webhook entegrasyonu ile commit bazlı tetikleme
- Deploy geçmişi, log kayıtları ve durum takibi (running/success/failed)
- Deploy metrikleri ve dashboard görselleştirmesi
- Webhook token ve secret yönetimi ile güvenlik
- Proje favicon servisi

Teknik değişiklikler:
- Backend: deploymentProject, deploymentRun ve settings modelleri eklendi
- Backend: deploymentService ile git ve docker işlemleri otomatize edildi
- Backend: webhook doğrulaması için signature kontrolü eklendi
- Docker: docker-cli ve docker-compose bağımlılıkları eklendi
- Frontend: deployments ve settings sayfaları eklendi
- Frontend: dashboard'a deploy metrikleri ve aktivite akışı eklendi
- API: /api/deployments ve /api/settings yolları eklendi
2026-01-18 16:24:11 +03:00
35 changed files with 4018 additions and 103 deletions

20
.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Backend Environment
PORT=4000
MONGO_URI=mongodb://mongo:27017/wisecoltci
ADMIN_USERNAME=admin
ADMIN_PASSWORD=supersecret
JWT_SECRET=change-me
CLIENT_ORIGIN=http://localhost:5173
# Frontend Environment
VITE_API_URL=http://localhost:4000/api
# ---------------------------------- CLAUDE API SETTINGS ---------------------------------- #
# === Claude API Config ===
API_KEY_LITE="your-lite-key"
API_KEY_PRO="your-pro-key"
ACTIVE_KEY=lite
# === Anthropic API Settings ===
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
ANTHROPIC_MODEL="glm-4.7"

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,17 +21,24 @@
- **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
- **Test Sonuçları**: Başarılı/başarısız sonuçların kaydedilmesi - **Test Sonuçları**: Başarılı/başarısız sonuçların kaydedilmesi
- **Log Akışı**: Gerçek zamanlı test loglarının izlenmesi - **Log Akışı**: Gerçek zamanlı test loglarının izlenmesi
### 🚀 Deployment Yönetimi
- **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
- **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ı
- **Güvenlik**: API Token + Webhook Secret ile doğrulama
### ⚡ 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
@@ -92,10 +99,15 @@ wisecolt-ci/
│ │ ├── 📁 middleware/ # Express middleware'leri │ │ ├── 📁 middleware/ # Express middleware'leri
│ │ │ └── authMiddleware.ts │ │ │ └── authMiddleware.ts
│ │ ├── 📁 models/ # MongoDB modelleri │ │ ├── 📁 models/ # MongoDB modelleri
│ │ │ ── job.ts │ │ │ ── job.ts
│ │ │ ├── deploymentProject.ts
│ │ │ ├── deploymentRun.ts
│ │ │ └── settings.ts
│ │ ├── 📁 routes/ # API route'ları │ │ ├── 📁 routes/ # API route'ları
│ │ │ ├── auth.ts │ │ │ ├── auth.ts
│ │ │ ── jobs.ts │ │ │ ── jobs.ts
│ │ │ ├── deployments.ts
│ │ │ └── webhooks.ts
│ │ ├── 📁 services/ # İş mantığı katmanı │ │ ├── 📁 services/ # İş mantığı katmanı
│ │ │ └── jobService.ts │ │ │ └── jobService.ts
│ │ └── 📄 index.ts # Ana sunucu dosyası │ │ └── 📄 index.ts # Ana sunucu dosyası
@@ -120,7 +132,10 @@ wisecolt-ci/
│ │ ├── 📁 pages/ # Sayfa bileşenleri │ │ ├── 📁 pages/ # Sayfa bileşenleri
│ │ │ ├── HomePage.tsx │ │ │ ├── HomePage.tsx
│ │ │ ├── JobsPage.tsx │ │ │ ├── JobsPage.tsx
│ │ │ ── JobDetailPage.tsx │ │ │ ── JobDetailPage.tsx
│ │ │ ├── DeploymentsPage.tsx
│ │ │ ├── DeploymentDetailPage.tsx
│ │ │ └── SettingsPage.tsx
│ │ ├── 📁 providers/ # React Context Provider'lar │ │ ├── 📁 providers/ # React Context Provider'lar
│ │ │ ├── auth-provider.tsx │ │ │ ├── auth-provider.tsx
│ │ │ ├── socket-provider.tsx │ │ │ ├── socket-provider.tsx
@@ -187,24 +202,37 @@ 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
1. **Deployments** sayfasına gidin
2. **New Deployment** ile Repo URL girin
3. Branch ve Compose dosyasını seçin
4. Kaydettikten sonra **Webhook URL**i Giteada web istemci olarak tanımlayın
#### Webhook Ayarları (Gitea)
- **Hedef URL**: `https://<domain>/api/deployments/webhook/<token>`
- **Yetkilendirme Başlığı**: `Bearer <API_TOKEN>`
- **Gizli**: `WEBHOOK_SECRET`
- **HTTP Yöntemi**: `POST`
- **İçerik Türü**: `application/json`
### Authentication ### Authentication
@@ -222,7 +250,9 @@ 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/branches`, `/deployments/compose-files`
- **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ı
@@ -319,8 +349,11 @@ ADMIN_PASSWORD=supersecret # Admin şifresi
JWT_SECRET=gizli-jwt-anahtari # JWT imzalama anahtarı JWT_SECRET=gizli-jwt-anahtari # JWT imzalama anahtarı
CLIENT_ORIGIN=http://localhost:5173 # Frontend adresi (CORS için) CLIENT_ORIGIN=http://localhost:5173 # Frontend adresi (CORS için)
# Docker Compose (.env)
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace # Zorunlu: host proje dizini
# Frontend (.env) # Frontend (.env)
VITE_API_URL=http://localhost:4000 # Backend API adresi VITE_API_URL=http://localhost:4000/api # Backend API adresi
``` ```
### Port Yapılandırması ### Port Yapılandırması
@@ -360,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
@@ -373,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

View File

@@ -3,7 +3,7 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
COPY package*.json . COPY package*.json .
RUN apk add --no-cache git openssh-client && npm install RUN apk add --no-cache git openssh-client docker-cli docker-cli-compose && npm install
COPY tsconfig.json . COPY tsconfig.json .
COPY src ./src COPY src ./src

View File

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

View File

@@ -5,9 +5,14 @@ import mongoose from "mongoose";
import { Server } from "socket.io"; import { Server } from "socket.io";
import authRoutes from "./routes/auth.js"; import authRoutes from "./routes/auth.js";
import jobsRoutes from "./routes/jobs.js"; import jobsRoutes from "./routes/jobs.js";
import deploymentsRoutes from "./routes/deployments.js";
import settingsRoutes from "./routes/settings.js";
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();
@@ -18,7 +23,13 @@ app.use(
credentials: true credentials: true
}) })
); );
app.use(express.json()); app.use(
express.json({
verify: (req, _res, buf) => {
(req as { rawBody?: Buffer }).rawBody = buf;
}
})
);
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
res.json({ status: "ok" }); res.json({ status: "ok" });
@@ -26,10 +37,14 @@ app.get("/health", (_req, res) => {
app.use("/api/auth", authRoutes); app.use("/api/auth", authRoutes);
app.use("/api/jobs", jobsRoutes); app.use("/api/jobs", jobsRoutes);
app.use("/", webhookRoutes);
app.use("/api/deployments", deploymentsRoutes);
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"]
@@ -37,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;
@@ -81,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

@@ -0,0 +1,53 @@
import mongoose, { Schema, Document } from "mongoose";
export type ComposeFile = "docker-compose.yml" | "docker-compose.dev.yml";
export type DeploymentStatus = "idle" | "running" | "success" | "failed";
export type DeploymentEnv = "dev" | "prod";
export interface DeploymentProjectDocument extends Document {
name: string;
rootPath: string;
repoUrl: string;
branch: string;
composeFile: ComposeFile;
webhookToken: string;
env: DeploymentEnv;
port?: number;
envContent?: string;
envExampleName?: string;
lastDeployAt?: Date;
lastStatus: DeploymentStatus;
lastMessage?: string;
createdAt: Date;
updatedAt: Date;
}
const DeploymentProjectSchema = new Schema<DeploymentProjectDocument>(
{
name: { type: String, required: true, trim: true },
rootPath: { type: String, required: true, trim: true },
repoUrl: { type: String, required: true, trim: true, unique: true, index: true },
branch: { type: String, required: true, trim: true },
composeFile: {
type: String,
required: true,
enum: ["docker-compose.yml", "docker-compose.dev.yml"]
},
webhookToken: { type: String, required: true, unique: true, index: true },
env: { type: String, required: true, enum: ["dev", "prod"] },
port: { type: Number },
envContent: { type: String },
envExampleName: { type: String },
lastDeployAt: { type: Date },
lastStatus: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" },
lastMessage: { type: String }
},
{ timestamps: true }
);
DeploymentProjectSchema.index({ rootPath: 1 });
export const DeploymentProject = mongoose.model<DeploymentProjectDocument>(
"DeploymentProject",
DeploymentProjectSchema
);

View File

@@ -0,0 +1,34 @@
import mongoose, { Schema, Document, Types } from "mongoose";
import { DeploymentProjectDocument } from "./deploymentProject.js";
export interface DeploymentRunDocument extends Document {
project: Types.ObjectId | DeploymentProjectDocument;
status: "running" | "success" | "failed";
message?: string;
logs: string[];
startedAt: Date;
finishedAt?: Date;
durationMs?: number;
createdAt: Date;
updatedAt: Date;
}
const DeploymentRunSchema = new Schema<DeploymentRunDocument>(
{
project: { type: Schema.Types.ObjectId, ref: "DeploymentProject", required: true },
status: { type: String, enum: ["running", "success", "failed"], required: true },
message: { type: String },
logs: { type: [String], default: [] },
startedAt: { type: Date, required: true },
finishedAt: { type: Date },
durationMs: { type: Number }
},
{ timestamps: true }
);
DeploymentRunSchema.index({ project: 1, startedAt: -1 });
export const DeploymentRun = mongoose.model<DeploymentRunDocument>(
"DeploymentRun",
DeploymentRunSchema
);

View File

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

View File

@@ -0,0 +1,249 @@
import { Router } from "express";
import path from "path";
import { authMiddleware } from "../middleware/authMiddleware.js";
import { deploymentService } from "../services/deploymentService.js";
import { DeploymentProject } from "../models/deploymentProject.js";
import { DeploymentRun } from "../models/deploymentRun.js";
import fs from "fs";
const router = Router();
const faviconCandidates = [
"favicon.ico",
"public/favicon.ico",
"public/favicon.png",
"public/favicon.svg",
"assets/favicon.ico"
];
function getContentType(filePath: string) {
if (filePath.endsWith(".svg")) return "image/svg+xml";
if (filePath.endsWith(".png")) return "image/png";
return "image/x-icon";
}
router.get("/:id/favicon", async (req, res) => {
const { id } = req.params;
const project = await DeploymentProject.findById(id).lean();
if (!project) return res.status(404).end();
const rootPath = path.resolve(project.rootPath);
for (const candidate of faviconCandidates) {
const filePath = path.join(rootPath, candidate);
if (!fs.existsSync(filePath)) continue;
res.setHeader("Content-Type", getContentType(filePath));
res.setHeader("Cache-Control", "public, max-age=300");
return fs.createReadStream(filePath).pipe(res);
}
return res.status(404).end();
});
router.get("/branches", async (req, res) => {
authMiddleware(req, res, async () => {
const repoUrl = req.query.repoUrl as string | undefined;
if (!repoUrl) {
return res.status(400).json({ message: "repoUrl gerekli" });
}
try {
const branches = await deploymentService.listRemoteBranches(repoUrl);
return res.json({ branches });
} catch (err) {
return res.status(400).json({ message: "Branch listesi alınamadı", error: (err as Error).message });
}
});
});
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) => {
authMiddleware(req, res, async () => {
const deploymentCount = await DeploymentProject.countDocuments();
if (deploymentCount === 0) {
await deploymentService.bootstrapFromFilesystem();
}
const since = new Date();
since.setDate(since.getDate() - 7);
const dailyStats = await DeploymentRun.aggregate([
{ $match: { startedAt: { $gte: since } } },
{
$group: {
_id: { $dateToString: { format: "%Y-%m-%d", date: "$startedAt" } },
total: { $sum: 1 },
success: {
$sum: {
$cond: [{ $eq: ["$status", "success"] }, 1, 0]
}
},
failed: {
$sum: {
$cond: [{ $eq: ["$status", "failed"] }, 1, 0]
}
},
avgDurationMs: { $avg: "$durationMs" }
}
},
{ $sort: { _id: 1 } }
]);
const recentRuns = await DeploymentRun.find()
.sort({ startedAt: -1 })
.limit(10)
.populate("project", "name repoUrl rootPath")
.lean();
return res.json({ recentRuns, dailyStats });
});
});
router.get("/", async (_req, res) => {
authMiddleware(_req, res, async () => {
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);
});
});
router.get("/:id", async (req, res) => {
authMiddleware(req, res, async () => {
const { id } = req.params;
const project = await DeploymentProject.findById(id).lean();
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
const runs = await DeploymentRun.find({ project: id })
.sort({ startedAt: -1 })
.limit(20)
.lean();
return res.json({ project, runs });
});
});
router.post("/", async (req, res) => {
authMiddleware(req, res, async () => {
const { name, repoUrl, branch, composeFile, port, envContent, envExampleName } = req.body;
if (!name || !repoUrl || !branch || !composeFile) {
return res.status(400).json({ message: "Tüm alanlar gerekli" });
}
try {
const created = await deploymentService.createProject({
name,
repoUrl,
branch,
composeFile,
port,
envContent,
envExampleName
});
deploymentService
.runDeployment(created._id.toString(), { message: "First deployment" })
.catch(() => undefined);
return res.status(201).json(created);
} catch (err) {
return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message });
}
});
});
router.put("/:id", async (req, res) => {
authMiddleware(req, res, async () => {
const { id } = req.params;
const { name, repoUrl, branch, composeFile, port, envContent, envExampleName } = req.body;
if (!name || !repoUrl || !branch || !composeFile) {
return res.status(400).json({ message: "Tüm alanlar gerekli" });
}
try {
const updated = await deploymentService.updateProject(id, {
name,
repoUrl,
branch,
composeFile,
port,
envContent,
envExampleName
});
if (!updated) return res.status(404).json({ message: "Deployment bulunamadı" });
return res.json(updated);
} catch (err) {
return res.status(400).json({ message: "Deployment güncellenemedi", error: (err as Error).message });
}
});
});
router.delete("/:id", async (req, res) => {
authMiddleware(req, res, async () => {
const { id } = req.params;
try {
const project = await DeploymentProject.findById(id);
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
await deploymentService.cleanupProjectResources(project);
await DeploymentProject.findByIdAndDelete(id);
await DeploymentRun.deleteMany({ project: id });
await fs.promises.rm(project.rootPath, { recursive: true, force: true });
return res.json({ success: true });
} catch (err) {
return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message });
}
});
});
router.post("/:id/run", 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 || "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 });
});
});
export default router;

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

@@ -0,0 +1,61 @@
import { Router } from "express";
import { authMiddleware } from "../middleware/authMiddleware.js";
import { deploymentService } from "../services/deploymentService.js";
const router = Router();
router.use(authMiddleware);
router.get("/", async (_req, res) => {
const settings = await deploymentService.ensureSettings();
return res.json({
webhookToken: settings.webhookToken,
webhookSecret: settings.webhookSecret,
cleanupIntervalValue: settings.cleanupIntervalValue,
cleanupIntervalUnit: settings.cleanupIntervalUnit,
updatedAt: settings.updatedAt
});
});
router.post("/token/rotate", async (_req, res) => {
const settings = await deploymentService.rotateToken();
return res.json({
webhookToken: settings.webhookToken,
updatedAt: settings.updatedAt
});
});
router.post("/secret/rotate", async (_req, res) => {
const settings = await deploymentService.rotateSecret();
return res.json({
webhookSecret: settings.webhookSecret,
updatedAt: settings.updatedAt
});
});
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;

View File

@@ -0,0 +1,74 @@
import { Router, Request } from "express";
import crypto from "crypto";
import { deploymentService, normalizeCommitMessage } from "../services/deploymentService.js";
const router = Router();
type RawBodyRequest = Request & { rawBody?: Buffer };
function getHeaderValue(value: string | string[] | undefined) {
if (!value) return "";
return Array.isArray(value) ? value[0] : value;
}
function verifySignature(rawBody: Buffer, secret: string, signature: string) {
const cleaned = signature.startsWith("sha256=") ? signature.slice(7) : signature;
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
if (cleaned.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(cleaned), Buffer.from(expected));
}
function normalizeBranch(value: string | undefined) {
const raw = (value || "").trim();
if (!raw) return "";
return raw.startsWith("refs/heads/") ? raw.replace("refs/heads/", "") : raw;
}
router.post("/api/deployments/webhook/:token", async (req, res) => {
const { token } = req.params;
const settings = await deploymentService.ensureSettings();
const authHeader = getHeaderValue(req.headers.authorization);
if (!authHeader) {
return res.status(401).json({ message: "Yetkisiz" });
}
const providedToken = authHeader.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: authHeader;
if (providedToken !== settings.webhookToken) {
return res.status(401).json({ message: "Yetkisiz" });
}
const signatureHeader =
getHeaderValue(req.headers["x-gitea-signature"]) ||
getHeaderValue(req.headers["x-gitea-signature-256"]);
const rawBody = (req as RawBodyRequest).rawBody;
if (!rawBody || !signatureHeader) {
return res.status(401).json({ message: "Imza eksik" });
}
if (!verifySignature(rawBody, settings.webhookSecret, signatureHeader)) {
return res.status(401).json({ message: "Imza dogrulanamadi" });
}
const payload = req.body as { ref?: string; head_commit?: { message?: string }; commits?: Array<{ message?: string }> };
const ref = payload?.ref || "";
const branch = normalizeBranch(ref);
const commitMessageRaw =
payload?.head_commit?.message || payload?.commits?.[payload.commits.length - 1]?.message;
const commitMessage = normalizeCommitMessage(commitMessageRaw);
const project = await deploymentService.findByWebhookToken(token);
if (!project) return res.status(404).json({ message: "Deployment bulunamadi" });
const projectBranch = normalizeBranch(project.branch);
if (projectBranch && projectBranch !== "*" && branch && branch !== projectBranch) {
return res.json({ ignored: true });
}
deploymentService
.runDeployment(project._id.toString(), commitMessage ? { message: commitMessage } : undefined)
.catch(() => undefined);
return res.json({ queued: true });
});
export default router;

View File

@@ -0,0 +1,984 @@
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { spawn } from "child_process";
import { Server } from "socket.io";
import { config } from "../config/env.js";
import {
DeploymentProject,
DeploymentProjectDocument,
ComposeFile,
DeploymentEnv
} from "../models/deploymentProject.js";
import { DeploymentRun, DeploymentRunDocument } from "../models/deploymentRun.js";
import { Settings } from "../models/settings.js";
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
const deploymentsRoot = config.deploymentsRoot;
const metadataFileName = ".wisecolt-ci.json";
const settingsFileName = ".wisecolt-ci-settings.json";
const runsDirName = ".wisecolt-ci-runs";
export function normalizeCommitMessage(message?: string) {
if (!message) return undefined;
const firstLine = message.split(/\r?\n/)[0]?.trim();
return firstLine || undefined;
}
type DeploymentMetadata = {
name: string;
repoUrl: string;
branch: string;
composeFile: ComposeFile;
webhookToken: string;
env: DeploymentEnv;
port?: number;
envContent?: string;
envExampleName?: string;
};
type SettingsMetadata = {
webhookToken: string;
webhookSecret: string;
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() {
return crypto.randomBytes(12).toString("base64url").slice(0, 16);
}
function generateApiToken() {
return crypto.randomBytes(24).toString("base64url");
}
function generateSecret() {
return crypto.randomBytes(32).toString("base64url");
}
function deriveEnv(composeFile: ComposeFile): DeploymentEnv {
return composeFile === "docker-compose.dev.yml" ? "dev" : "prod";
}
function runCommand(command: string, cwd: string, onData: (chunk: string) => void) {
return new Promise<void>((resolve, reject) => {
const child = spawn(command, {
cwd,
shell: true,
env: { ...process.env, CI: process.env.CI || "1" }
});
const emitLines = (chunk: Buffer) => {
const cleaned = chunk.toString().replace(/\r\n|\r/g, "\n");
cleaned.split("\n").forEach((line) => {
if (line.trim().length > 0) onData(line);
});
};
child.stdout.on("data", emitLines);
child.stderr.on("data", emitLines);
child.on("error", (err) => {
onData(`Hata: ${err.message}`);
reject(err);
});
child.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Komut kod ${code} ile kapandı`));
}
});
});
}
function runCommandCapture(command: string, args: string[], cwd: string) {
return new Promise<string>((resolve, reject) => {
const child = spawn(command, args, { cwd });
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", (err) => {
reject(err);
});
child.on("close", (code) => {
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(stderr.trim() || `Komut kod ${code} ile kapandı`));
}
});
});
}
async function ensureSafeDirectory(repoDir: string, onData: (line: string) => void) {
onData(`Git safe.directory ekleniyor: ${repoDir}`);
await runCommand(`git config --global --add safe.directory ${repoDir}`, process.cwd(), onData);
}
async function ensureRepo(project: DeploymentProjectDocument, onData: (line: string) => void) {
const repoDir = project.rootPath;
const gitDir = path.join(repoDir, ".git");
const exists = fs.existsSync(gitDir);
await ensureSafeDirectory(repoDir, onData);
if (!exists) {
const entries = await fs.promises.readdir(repoDir);
const allowed = new Set<string>([metadataFileName, ".env", ".env.local", runsDirName]);
const blocking = entries.filter((name) => !allowed.has(name));
if (blocking.length > 0) {
throw new Error("Repo klasoru git olmayan dosyalar iceriyor");
}
let envBackup: string | null = null;
const envPath = path.join(repoDir, ".env");
if (fs.existsSync(envPath)) {
envBackup = await fs.promises.readFile(envPath, "utf8");
}
await Promise.all(
entries
.filter((name) => allowed.has(name))
.map((name) => fs.promises.rm(path.join(repoDir, name), { force: true }))
);
onData(`Repo klonlanıyor: ${project.repoUrl}`);
await runCommand(`git clone --branch ${project.branch} ${project.repoUrl} .`, repoDir, onData);
if (envBackup) {
await fs.promises.writeFile(envPath, envBackup, "utf8");
}
await writeMetadata(repoDir, {
name: project.name,
repoUrl: project.repoUrl,
branch: project.branch,
composeFile: project.composeFile,
webhookToken: project.webhookToken,
env: project.env,
port: project.port,
envContent: project.envContent,
envExampleName: project.envExampleName
});
} else {
onData("Repo güncelleniyor (git fetch/pull)...");
await runCommand(`git fetch origin ${project.branch}`, repoDir, onData);
try {
await runCommand(`git checkout ${project.branch}`, repoDir, onData);
} catch {
await runCommand(`git checkout -b ${project.branch} origin/${project.branch}`, repoDir, onData);
}
await runCommand(`git pull origin ${project.branch}`, repoDir, onData);
}
}
async function runCompose(project: DeploymentProjectDocument, onData: (line: string) => void) {
const composePath = path.join(project.rootPath, project.composeFile);
if (!fs.existsSync(composePath)) {
throw new Error("Compose dosyası bulunamadı");
}
onData("Docker compose down çalıştırılıyor...");
await runCommand(`docker compose -f ${project.composeFile} down`, project.rootPath, onData);
onData("Docker compose up (build) çalıştırılıyor...");
await runCommand(
`docker compose -f ${project.composeFile} up -d --build`,
project.rootPath,
onData
);
}
class DeploymentService {
private running: Map<string, boolean> = new Map();
private io: Server | null = null;
private cleanupTimer: NodeJS.Timeout | null = null;
setSocket(io: Server) {
this.io = io;
}
private async emitStatus(deploymentId: string, payload: Partial<DeploymentProjectDocument>) {
if (!this.io) return;
const runCount = await DeploymentRun.countDocuments({ project: deploymentId });
const body = {
deploymentId,
status: payload.lastStatus,
lastRunAt: payload.lastDeployAt,
lastMessage: payload.lastMessage,
runCount
};
this.io.to(`deployment:${deploymentId}`).emit("deployment:status", body);
this.io.emit("deployment:status", body);
}
private emitLog(deploymentId: string, line: string) {
if (!this.io) return;
this.io.to(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
this.io.except(`deployment:${deploymentId}`).emit("deployment:log", { deploymentId, line });
}
private emitRun(deploymentId: string, run: DeploymentRunDocument) {
if (!this.io) return;
this.io.to(`deployment:${deploymentId}`).emit("deployment:run", {
deploymentId,
run
});
}
async listRemoteBranches(repoUrl: string) {
const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd());
const branches = output
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.split("\t")[1] || "")
.filter((ref) => ref.startsWith("refs/heads/"))
.map((ref) => ref.replace("refs/heads/", ""));
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() {
const existing = await Settings.findOne();
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({
webhookToken: generateApiToken(),
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;
}
async rotateToken() {
const settings = await this.ensureSettings();
settings.webhookToken = generateApiToken();
await settings.save();
await writeSettingsFile({
webhookToken: settings.webhookToken,
webhookSecret: settings.webhookSecret,
cleanupIntervalValue: settings.cleanupIntervalValue,
cleanupIntervalUnit: settings.cleanupIntervalUnit
});
return settings;
}
async rotateSecret() {
const settings = await this.ensureSettings();
settings.webhookSecret = generateSecret();
await settings.save();
await writeSettingsFile({
webhookToken: settings.webhookToken,
webhookSecret: settings.webhookSecret,
cleanupIntervalValue: settings.cleanupIntervalValue,
cleanupIntervalUnit: settings.cleanupIntervalUnit
});
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: {
name: string;
repoUrl: string;
branch: string;
composeFile: ComposeFile;
port?: number;
envContent?: string;
envExampleName?: string;
}) {
const repoUrl = normalizeRepoUrl(input.repoUrl);
const existingRepo = await DeploymentProject.findOne({ repoUrl });
if (existingRepo) {
throw new Error("Bu repo zaten eklenmiş");
}
let webhookToken = generateWebhookToken();
while (await DeploymentProject.findOne({ webhookToken })) {
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 created = await DeploymentProject.create({
name: input.name,
rootPath,
repoUrl,
branch: input.branch,
composeFile: input.composeFile,
webhookToken,
env,
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(
id: string,
input: {
name: string;
repoUrl: string;
branch: string;
composeFile: ComposeFile;
port?: number;
envContent?: string;
envExampleName?: string;
}
) {
const project = await DeploymentProject.findById(id);
if (!project) return null;
const repoUrl = normalizeRepoUrl(input.repoUrl);
if (repoUrl !== project.repoUrl) {
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 updated = await DeploymentProject.findByIdAndUpdate(
id,
{
name: input.name,
repoUrl,
branch: input.branch,
composeFile: input.composeFile,
env,
port: input.port,
envContent: input.envContent,
envExampleName: input.envExampleName
},
{ new: true, runValidators: true }
);
if (updated) {
await writeMetadata(updated.rootPath, {
name: updated.name,
repoUrl: updated.repoUrl,
branch: updated.branch,
composeFile: updated.composeFile,
webhookToken: updated.webhookToken,
env: updated.env,
port: updated.port,
envContent: updated.envContent,
envExampleName: updated.envExampleName
});
}
return updated;
}
async runDeployment(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 ?? "Deploy başlıyor..."
});
await this.emitStatus(projectId, {
lastStatus: "running",
lastMessage: normalizedMessage ?? options?.message ?? "Deploy başlıyor..."
} as DeploymentProjectDocument);
try {
await ensureRepo(project, (line) => pushLog(line));
if (project.envContent) {
await fs.promises.writeFile(path.join(project.rootPath, ".env"), project.envContent, "utf8");
pushLog(".env güncellendi");
}
pushLog("Deploy komutları çalıştırılıyor...");
await runCompose(project, (line) => pushLog(line));
const duration = Date.now() - startedAt;
await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "success",
lastDeployAt: new Date(),
lastMessage: normalizedMessage ?? options?.message ?? "Başarılı"
});
await this.emitStatus(projectId, {
lastStatus: "success",
lastDeployAt: new Date(),
lastMessage: normalizedMessage ?? options?.message ?? "Başarılı"
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "success",
finishedAt: new Date(),
durationMs: duration,
logs: runLogs,
message: normalizedMessage ?? options?.message
});
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
pushLog("Deploy tamamlandı: Başarılı");
} catch (err) {
const duration = Date.now() - startedAt;
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 restartDeployment(projectId: string, options?: { message?: string }) {
if (this.running.get(projectId)) {
return;
}
this.running.set(projectId, true);
const project = await DeploymentProject.findById(projectId);
if (!project) {
this.running.delete(projectId);
return;
}
const normalizedMessage = normalizeCommitMessage(options?.message);
const startedAt = Date.now();
const runLogs: string[] = [];
const pushLog = (line: string) => {
runLogs.push(line);
this.emitLog(projectId, line);
};
const runDoc = await DeploymentRun.create({
project: projectId,
status: "running",
startedAt: new Date(),
message: normalizedMessage ?? options?.message
});
this.emitRun(projectId, runDoc);
await writeRunFile(project.rootPath, runDoc);
await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "running",
lastMessage: normalizedMessage ?? options?.message ?? "Restart başlıyor..."
});
await this.emitStatus(projectId, {
lastStatus: "running",
lastMessage: normalizedMessage ?? options?.message ?? "Restart başlıyor..."
} as DeploymentProjectDocument);
try {
pushLog("Restart komutları çalıştırılıyor...");
await runCompose(project, (line) => pushLog(line));
const duration = Date.now() - startedAt;
await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "success",
lastDeployAt: new Date(),
lastMessage: normalizedMessage ?? options?.message ?? "Restart başarılı"
});
await this.emitStatus(projectId, {
lastStatus: "success",
lastDeployAt: new Date(),
lastMessage: normalizedMessage ?? options?.message ?? "Restart başarılı"
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "success",
finishedAt: new Date(),
durationMs: duration,
logs: runLogs,
message: normalizedMessage ?? options?.message
});
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
pushLog("Restart tamamlandı: Başarılı");
} catch (err) {
const duration = Date.now() - startedAt;
await DeploymentProject.findByIdAndUpdate(projectId, {
lastStatus: "failed",
lastDeployAt: new Date(),
lastMessage: (err as Error).message
});
await this.emitStatus(projectId, {
lastStatus: "failed",
lastDeployAt: new Date(),
lastMessage: (err as Error).message
} as DeploymentProjectDocument);
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
status: "failed",
finishedAt: new Date(),
durationMs: duration,
logs: runLogs,
message: normalizedMessage ?? options?.message
});
const updatedRun = await DeploymentRun.findById(runDoc._id);
if (updatedRun) this.emitRun(projectId, updatedRun);
if (updatedRun) await writeRunFile(project.rootPath, updatedRun);
pushLog(`Hata: ${(err as Error).message}`);
} finally {
this.running.delete(projectId);
}
}
async cleanupProjectResources(project: DeploymentProjectDocument) {
const composePath = path.join(project.rootPath, project.composeFile);
if (!fs.existsSync(composePath)) {
return;
}
await runCommand(
`docker compose -f ${project.composeFile} down --remove-orphans -v --rmi local`,
project.rootPath,
() => undefined
);
}
async findByWebhookToken(token: string) {
return DeploymentProject.findOne({ webhookToken: token });
}
async normalizeExistingCommitMessages() {
const projects = await DeploymentProject.find({
lastMessage: { $regex: /[\r\n]/ }
});
for (const project of projects) {
const normalized = normalizeCommitMessage(project.lastMessage);
if (normalized && normalized !== project.lastMessage) {
project.lastMessage = normalized;
await project.save();
}
}
const runs = await DeploymentRun.find({
message: { $regex: /[\r\n]/ }
});
for (const run of runs) {
const normalized = normalizeCommitMessage(run.message);
if (normalized && normalized !== run.message) {
run.message = normalized;
await run.save();
}
}
}
async bootstrapFromFilesystem() {
const candidateRoots = [
deploymentsRoot,
path.resolve(process.cwd(), "deployments"),
path.resolve(process.cwd(), "..", "deployments"),
path.resolve(process.cwd(), "..", "..", "deployments"),
"/root/Wisecolt-CI/deployments"
];
const roots = Array.from(
new Set(candidateRoots.filter((root) => root && fs.existsSync(root)))
);
for (const root of roots) {
const entries = await fs.promises.readdir(root, { withFileTypes: true });
const dirs = entries.filter((entry) => entry.isDirectory());
for (const entry of dirs) {
const rootPath = path.join(root, entry.name);
const existing = await DeploymentProject.findOne({ rootPath });
if (existing) continue;
const metadata = await readMetadata(rootPath);
const repoUrlRaw = metadata?.repoUrl || (await inferRepoUrlFromGit(rootPath));
if (!repoUrlRaw) continue;
const repoUrl = normalizeRepoUrl(repoUrlRaw);
const repoExisting = await DeploymentProject.findOne({ repoUrl });
if (repoExisting) continue;
const composeFile = metadata?.composeFile || inferComposeFile(rootPath);
if (!composeFile) continue;
const branch = metadata?.branch || (await inferBranchFromGit(rootPath)) || "main";
const name = metadata?.name || inferName(repoUrl, rootPath);
let webhookToken = metadata?.webhookToken || generateWebhookToken();
while (await DeploymentProject.findOne({ webhookToken })) {
webhookToken = generateWebhookToken();
}
let envContent = metadata?.envContent;
const envPath = path.join(rootPath, ".env");
if (!envContent && fs.existsSync(envPath)) {
envContent = await fs.promises.readFile(envPath, "utf8");
}
const envExampleName = metadata?.envExampleName;
const env = deriveEnv(composeFile);
const created = await DeploymentProject.create({
name,
rootPath,
repoUrl,
branch,
composeFile,
webhookToken,
env,
port: metadata?.port,
envContent,
envExampleName
});
await writeMetadata(rootPath, {
name: created.name,
repoUrl: created.repoUrl,
branch: created.branch,
composeFile: created.composeFile,
webhookToken: created.webhookToken,
env: created.env,
port: created.port,
envContent: created.envContent,
envExampleName: created.envExampleName
});
const storedRuns = await readStoredRuns(rootPath);
if (storedRuns.length > 0) {
storedRuns.sort(
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
await DeploymentRun.insertMany(
storedRuns.map((run) => ({
project: created._id,
status: run.status,
message: run.message,
logs: run.logs || [],
startedAt: new Date(run.startedAt),
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
durationMs: run.durationMs,
createdAt: new Date(run.createdAt),
updatedAt: new Date(run.updatedAt)
}))
);
const latest = storedRuns[0];
await DeploymentProject.findByIdAndUpdate(created._id, {
lastStatus: latest.status,
lastDeployAt: new Date(latest.finishedAt || latest.startedAt),
lastMessage: latest.message
});
}
}
}
}
}
export const deploymentService = new DeploymentService();
export { generateApiToken, generateSecret };

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,8 +13,12 @@ services:
volumes: volumes:
- ./backend:/app - ./backend:/app
- /app/node_modules - /app/node_modules
- ${PWD}:${PWD}
- /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:
@@ -27,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

@@ -2,8 +2,13 @@ services:
backend: backend:
build: ./backend build: ./backend
command: npm run build && npm start command: npm run build && npm start
volumes:
- ${PWD}:${PWD}
- /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"
@@ -14,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
# 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

@@ -5,6 +5,9 @@ import { DashboardLayout } from "./components/DashboardLayout";
import { HomePage } from "./pages/HomePage"; import { HomePage } from "./pages/HomePage";
import { JobsPage } from "./pages/JobsPage"; import { JobsPage } from "./pages/JobsPage";
import { JobDetailPage } from "./pages/JobDetailPage"; import { JobDetailPage } from "./pages/JobDetailPage";
import { DeploymentsPage } from "./pages/DeploymentsPage";
import { DeploymentDetailPage } from "./pages/DeploymentDetailPage";
import { SettingsPage } from "./pages/SettingsPage";
function App() { function App() {
return ( return (
@@ -15,6 +18,9 @@ function App() {
<Route path="/home" element={<HomePage />} /> <Route path="/home" element={<HomePage />} />
<Route path="/jobs" element={<JobsPage />} /> <Route path="/jobs" element={<JobsPage />} />
<Route path="/jobs/:id" element={<JobDetailPage />} /> <Route path="/jobs/:id" element={<JobDetailPage />} />
<Route path="/deployments" element={<DeploymentsPage />} />
<Route path="/deployments/:id" element={<DeploymentDetailPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/home" replace />} /> <Route path="*" element={<Navigate to="/home" replace />} />
</Route> </Route>
</Route> </Route>

View File

@@ -0,0 +1,131 @@
import { apiClient } from "./client";
export type ComposeFile = "docker-compose.yml" | "docker-compose.dev.yml";
export type DeploymentStatus = "idle" | "running" | "success" | "failed";
export type DeploymentEnv = "dev" | "prod";
export interface DeploymentProject {
_id: string;
name: string;
rootPath: string;
repoUrl: string;
branch: string;
composeFile: ComposeFile;
webhookToken: string;
env: DeploymentEnv;
port?: number;
envContent?: string;
envExampleName?: string;
lastDeployAt?: string;
lastStatus: DeploymentStatus;
lastMessage?: string;
createdAt: string;
updatedAt: string;
}
export interface DeploymentRun {
_id: string;
project: string;
status: "running" | "success" | "failed";
message?: string;
logs: string[];
startedAt: string;
finishedAt?: string;
durationMs?: number;
createdAt: string;
updatedAt: string;
}
export interface DeploymentRunWithProject extends Omit<DeploymentRun, "project"> {
project: DeploymentProject;
}
export interface DeploymentDetailResponse {
project: DeploymentProject;
runs: DeploymentRun[];
}
export interface DeploymentMetrics {
dailyStats: Array<{
_id: string;
total: number;
success: number;
failed: number;
avgDurationMs?: number;
}>;
recentRuns: DeploymentRunWithProject[];
}
export interface DeploymentInput {
name: string;
repoUrl: string;
branch: string;
composeFile: ComposeFile;
port?: number;
envContent?: string;
envExampleName?: string;
}
export async function fetchDeployments(): Promise<DeploymentProject[]> {
const { data } = await apiClient.get("/deployments");
return data as DeploymentProject[];
}
export async function fetchDeploymentBranches(repoUrl: string): Promise<string[]> {
const { data } = await apiClient.get("/deployments/branches", {
params: { repoUrl }
});
return (data as { branches: string[] }).branches;
}
export async function createDeployment(payload: DeploymentInput): Promise<DeploymentProject> {
const { data } = await apiClient.post("/deployments", payload);
return data as DeploymentProject;
}
export async function updateDeployment(id: string, payload: DeploymentInput) {
const { data } = await apiClient.put(`/deployments/${id}`, payload);
return data as DeploymentProject;
}
export async function deleteDeployment(id: string): Promise<void> {
await apiClient.delete(`/deployments/${id}`);
}
export async function runDeployment(id: string, message?: string): Promise<void> {
await apiClient.post(`/deployments/${id}/run`, message ? { message } : {});
}
export async function restartDeployment(id: string, message?: string): Promise<void> {
await apiClient.post(`/deployments/${id}/restart`, message ? { message } : {});
}
export async function fetchDeployment(id: string): Promise<DeploymentDetailResponse> {
const { data } = await apiClient.get(`/deployments/${id}`);
return data as DeploymentDetailResponse;
}
export async function fetchDeploymentMetrics(): Promise<DeploymentMetrics> {
const { data } = await apiClient.get("/deployments/metrics/summary");
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

@@ -0,0 +1,34 @@
import { apiClient } from "./client";
export interface SettingsResponse {
webhookToken: string;
webhookSecret: string;
cleanupIntervalValue?: number;
cleanupIntervalUnit?: "saat" | "gün" | "hafta";
updatedAt: string;
}
export async function fetchSettings(): Promise<SettingsResponse> {
const { data } = await apiClient.get("/settings");
return data as SettingsResponse;
}
export async function rotateWebhookToken(): Promise<SettingsResponse> {
const { data } = await apiClient.post("/settings/token/rotate");
return data as SettingsResponse;
}
export async function rotateWebhookSecret(): Promise<SettingsResponse> {
const { data } = await apiClient.post("/settings/secret/rotate");
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

@@ -1,7 +1,14 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHouse, faBriefcase, faArrowRightFromBracket, faUser, faFlaskVial } from "@fortawesome/free-solid-svg-icons"; import {
faHouse,
faArrowRightFromBracket,
faUser,
faFlaskVial,
faRocket,
faGear
} from "@fortawesome/free-solid-svg-icons";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
import { useAuth } from "../providers/auth-provider"; import { useAuth } from "../providers/auth-provider";
@@ -15,7 +22,9 @@ 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: "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

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

View File

@@ -0,0 +1,690 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { toast } from "sonner";
import { useLocation, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCloudArrowUp,
faEye,
faEyeSlash,
faPenToSquare,
faPlus,
faRotate,
faRocket
} from "@fortawesome/free-solid-svg-icons";
import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import {
createDeployment,
deleteDeployment,
DeploymentInput,
DeploymentProject,
fetchDeploymentComposeFiles,
fetchDeploymentBranches,
fetchDeploymentEnvExamples,
fetchDeployments,
restartDeployment,
runDeployment,
updateDeployment
} from "../api/deployments";
import { JobStatusBadge } from "../components/JobStatusBadge";
import { useLiveData } from "../providers/live-provider";
type FormState = {
_id?: string;
name: string;
repoUrl: string;
branch: string;
composeFile: DeploymentInput["composeFile"];
port: string;
};
type EnvExample = { name: string; content: string };
const defaultForm: FormState = {
name: "",
repoUrl: "",
branch: "main",
composeFile: "docker-compose.yml",
port: ""
};
export function DeploymentsPage() {
const navigate = useNavigate();
const location = useLocation();
const { deploymentStreams } = useLiveData();
const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "");
const [deployments, setDeployments] = useState<DeploymentProject[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>(defaultForm);
const [pendingEditId, setPendingEditId] = useState<string | null>(null);
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 [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({});
const isEdit = useMemo(() => !!form._id, [form._id]);
const loadDeployments = async () => {
setLoading(true);
try {
const data = await fetchDeployments();
setDeployments(data);
} catch {
toast.error("Deployment listesi alınamadı");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadDeployments();
}, []);
useEffect(() => {
const repoUrl = form.repoUrl.trim();
if (!repoUrl) {
setBranchOptions([]);
setComposeOptions([]);
return;
}
if (!form._id && !form.name) {
const normalized = repoUrl.replace(/\/+$/, "");
const lastPart = normalized.split("/").pop() || "";
const name = lastPart.replace(/\.git$/i, "");
if (name) {
setForm((prev) => ({ ...prev, name }));
}
}
const timer = setTimeout(async () => {
setBranchLoading(true);
try {
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("");
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(() => {
const state = location.state as { editDeploymentId?: string } | null;
if (state?.editDeploymentId) {
setPendingEditId(state.editDeploymentId);
navigate(location.pathname, { replace: true });
}
}, [location.state, navigate, location.pathname]);
useEffect(() => {
if (!pendingEditId || deployments.length === 0) return;
const deployment = deployments.find((d) => d._id === pendingEditId);
if (deployment) {
handleEdit(deployment);
setPendingEditId(null);
}
}, [pendingEditId, deployments]);
const handleOpenNew = async () => {
setForm(defaultForm);
setBranchOptions([]);
setComposeOptions([]);
setEnvExamples([]);
setEnvContent("");
setEnvExampleName("");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true);
};
const handleEdit = (deployment: DeploymentProject) => {
const { _id, name, repoUrl, branch, composeFile, port } = deployment;
setForm({
_id,
name,
repoUrl,
branch,
composeFile,
port: port ? String(port) : ""
});
setEnvContent(deployment.envContent || "");
setEnvExampleName(deployment.envExampleName || "");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true);
};
const handleClose = () => {
setModalOpen(false);
};
const handleSave = async () => {
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;
}
if (isEdit && form._id) {
const updated = await updateDeployment(form._id, {
name: payload.name,
repoUrl: payload.repoUrl,
branch: payload.branch,
composeFile: payload.composeFile,
port: payload.port,
envContent: payload.envContent,
envExampleName: payload.envExampleName
});
setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d)));
try {
await runDeployment(updated._id, "update deploy");
} catch {
toast.error("Deploy tetiklenemedi");
}
toast.success("Deployment güncellendi");
} else {
const created = await createDeployment(payload);
setDeployments((prev) => [created, ...prev]);
toast.success("Deployment oluşturuldu");
}
setModalOpen(false);
} catch {
toast.error("İşlem sırasında hata oluştu");
} finally {
setSaving(false);
}
};
const handleRun = async (id: string) => {
try {
await runDeployment(id);
toast.success("Deploy tetiklendi");
} catch {
toast.error("Deploy tetiklenemedi");
}
};
const handleRestart = async (id: string) => {
try {
await restartDeployment(id, "restart");
toast.success("Restart tetiklendi");
} catch {
toast.error("Restart tetiklenemedi");
}
};
const handleDelete = async (deployment: DeploymentProject) => {
const ok = window.confirm("Bu deployment'ı silmek istediğinize emin misiniz?");
if (!ok) return;
try {
await deleteDeployment(deployment._id);
setDeployments((prev) => prev.filter((d) => d._id !== deployment._id));
toast.success("Deployment silindi");
} catch {
toast.error("Deployment silinemedi");
}
};
const formatDate = (value?: string) => {
if (!value) return "-";
return new Date(value).toLocaleString();
};
return (
<>
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-foreground">Deployments</h2>
</div>
<Button onClick={handleOpenNew} className="gap-2">
<FontAwesomeIcon icon={faPlus} className="h-4 w-4" />
New Deployment
</Button>
</div>
<div className="grid gap-4">
{loading && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Deployments yükleniyor...
</div>
)}
{!loading && deployments.length === 0 && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
Henüz deployment eklenmemiş.
</div>
)}
{deployments.map((deployment) => {
const faviconUrl = apiBase
? `${apiBase}/deployments/${deployment._id}/favicon`
: `/api/deployments/${deployment._id}/favicon`;
return (
<Card
key={deployment._id}
className="cursor-pointer transition hover:border-primary/50"
onClick={() => navigate(`/deployments/${deployment._id}`)}
>
<CardContent className="p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-primary">
{!faviconErrors[deployment._id] ? (
<img
src={faviconUrl}
alt={`${deployment.name} favicon`}
className="h-4 w-4"
onError={() =>
setFaviconErrors((prev) => ({ ...prev, [deployment._id]: true }))
}
/>
) : (
<FontAwesomeIcon icon={faRocket} className="h-4 w-4" />
)}
</div>
<div>
<div className="text-base font-semibold text-foreground">{deployment.name}</div>
<div className="text-sm text-muted-foreground">{deployment.rootPath}</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<JobStatusBadge
status={deploymentStreams[deployment._id]?.status || deployment.lastStatus}
/>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
{deployment.env.toUpperCase()}
</span>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
{deployment.composeFile}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleRun(deployment._id);
}}
title="Deploy tetikle"
>
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleRestart(deployment._id);
}}
title="Restart"
aria-label="Restart"
>
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleEdit(deployment);
}}
title="Düzenle"
>
<FontAwesomeIcon icon={faPenToSquare} className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDelete(deployment);
}}
title="Sil"
>
</Button>
</div>
</div>
<div className="mt-4 grid gap-1 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">Repo:</span>
<span className="truncate text-foreground/80">{deployment.repoUrl}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">Branch:</span>
<span className="text-foreground/80">{deployment.branch}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">Last Deploy:</span>
<span className="text-foreground/80">{formatDate(deployment.lastDeployAt)}</span>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
<div
className="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"
style={{ height: 626 }}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="space-y-1">
<div className="text-lg font-semibold text-foreground">
{isEdit ? "Deployment Güncelle" : "Yeni Deployment"}
</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,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,
@@ -13,10 +13,12 @@ import {
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { useLiveData } from "../providers/live-provider"; import { useLiveData } from "../providers/live-provider";
import { fetchJobMetrics, JobMetrics } from "../api/jobs"; import { fetchJobMetrics, JobMetrics } from "../api/jobs";
import { fetchDeploymentMetrics, DeploymentMetrics, DeploymentRunWithProject } from "../api/deployments";
import { JobStatusBadge } from "../components/JobStatusBadge"; 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 } 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 "-";
@@ -29,28 +31,102 @@ function formatDuration(ms?: number) {
return `${hours}sa ${minutes % 60}dk`; return `${hours}sa ${minutes % 60}dk`;
} }
function toYmd(date: Date) {
return date.toISOString().slice(0, 10);
}
export function HomePage() { export function HomePage() {
const [metrics, setMetrics] = useState<JobMetrics | null>(null); const [metrics, setMetrics] = useState<JobMetrics | null>(null);
const [deploymentMetrics, setDeploymentMetrics] = useState<DeploymentMetrics | null>(null);
const [deployRuns, setDeployRuns] = useState<DeploymentRunWithProject[]>([]);
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();
const loadMetrics = useCallback(() => {
if (!token) return;
setLoading(true);
setError(null);
Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()])
.then(([jobResult, deployResult]) => {
if (jobResult.status === "fulfilled") {
setMetrics(jobResult.value);
} else {
setMetrics({
dailyStats: [],
recentRuns: [],
totals: { successRate: 0, totalRuns: 0 }
});
setError("Test metrikleri alınamadı");
}
if (deployResult.status === "fulfilled") {
setDeploymentMetrics(deployResult.value);
setDeployRuns(deployResult.value.recentRuns || []);
} else {
setDeploymentMetrics({ dailyStats: [], recentRuns: [] });
}
})
.finally(() => setLoading(false));
}, [token]);
useEffect(() => { useEffect(() => {
fetchJobMetrics() loadMetrics();
.then(setMetrics) }, [loadMetrics, location.key]);
.catch(() => setError("Metrikler alınamadı"))
.finally(() => setLoading(false)); 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) return []; if (!metrics) {
return metrics.dailyStats.map((d) => ({ const days = Array.from({ length: 7 }).map((_, idx) => {
date: d._id, const date = new Date();
Başarılı: d.success, date.setDate(date.getDate() - (6 - idx));
Hatalı: d.failed return toYmd(date);
})); });
}, [metrics]); return days.map((date) => ({
date,
"Test Başarılı": 0,
"Test Hatalı": 0,
"Deploy Başarılı": 0,
"Deploy Hatalı": 0
}));
}
const deployMap = new Map((deploymentMetrics?.dailyStats || []).map((d) => [d._id, d]));
const jobMap = new Map(metrics.dailyStats.map((d) => [d._id, d]));
const days = Array.from({ length: 7 }).map((_, idx) => {
const date = new Date();
date.setDate(date.getDate() - (6 - idx));
return toYmd(date);
});
return days.map((date) => {
const job = jobMap.get(date);
const deploy = deployMap.get(date);
return {
date,
"Test Başarılı": job?.success || 0,
"Test Hatalı": job?.failed || 0,
"Deploy Başarılı": deploy?.success || 0,
"Deploy Hatalı": deploy?.failed || 0
};
});
}, [metrics, deploymentMetrics]);
const mergedRuns = useMemo(() => { const mergedRuns = useMemo(() => {
if (!metrics) return []; if (!metrics) return [];
@@ -69,7 +145,53 @@ export function HomePage() {
}); });
}, [metrics, jobStreams]); }, [metrics, jobStreams]);
const lastRunDuration = useMemo(() => formatDuration(mergedRuns[0]?.durationMs), [mergedRuns]); const activityItems = useMemo(() => {
const jobItems = mergedRuns.map((run) => ({
id: run._id,
type: "test" as const,
title: run.job.name,
repoUrl: run.job.repoUrl,
status: run.status,
startedAt: run.startedAt,
durationMs: run.durationMs,
link: `/jobs/${run.job._id}`
}));
const deployItems = deployRuns.map((run) => ({
id: run._id,
type: "deploy" as const,
title: run.project.name,
repoUrl: run.project.repoUrl,
status: run.status,
startedAt: run.startedAt,
durationMs: run.durationMs,
message: run.message,
link: `/deployments/${run.project._id}`
}));
return [...jobItems, ...deployItems]
.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())
.slice(0, 10);
}, [mergedRuns, deployRuns]);
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">
@@ -78,14 +200,14 @@ export function HomePage() {
<CardHeader className="flex items-center justify-between"> <CardHeader className="flex items-center justify-between">
<div> <div>
<CardTitle>Son 7 Gün Çalıştırma Trendleri</CardTitle> <CardTitle>Son 7 Gün Çalıştırma Trendleri</CardTitle>
<CardDescription>Başarılı / Hatalı job sayıları</CardDescription> <CardDescription>Test ve Deploy sonuçları</CardDescription>
</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-80 min-w-0"> <CardContent className="h-48 min-w-0">
{loading ? ( {loading ? (
<div className="text-sm text-muted-foreground">Yükleniyor...</div> <div className="text-sm text-muted-foreground">Yükleniyor...</div>
) : chartData.length === 0 ? ( ) : chartData.length === 0 ? (
@@ -96,10 +218,24 @@ export function HomePage() {
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" /> <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="date" /> <XAxis dataKey="date" />
<YAxis allowDecimals={false} /> <YAxis allowDecimals={false} />
<Tooltip /> <Tooltip
wrapperStyle={{ zIndex: 50 }}
contentStyle={{
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
boxShadow: "0 10px 20px rgba(0,0,0,0.12)",
color: "#111827",
opacity: 1
}}
labelStyle={{ color: "#111827", fontWeight: 600 }}
itemStyle={{ color: "#111827" }}
/>
<Legend /> <Legend />
<Line type="monotone" dataKey="Başarılı" stroke="#10b981" strokeWidth={2} /> <Line type="monotone" dataKey="Test Başarılı" stroke="#10b981" strokeWidth={2} />
<Line type="monotone" dataKey="Hatalı" stroke="#ef4444" strokeWidth={2} /> <Line type="monotone" dataKey="Test Hatalı" stroke="#ef4444" strokeWidth={2} />
<Line type="monotone" dataKey="Deploy Başarılı" stroke="#f59e0b" strokeWidth={2} />
<Line type="monotone" dataKey="Deploy Hatalı" stroke="#f97316" strokeWidth={2} />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
)} )}
@@ -111,17 +247,17 @@ export function HomePage() {
<CardTitle>Hızlı Metrikler</CardTitle> <CardTitle>Hızlı Metrikler</CardTitle>
<CardDescription>Özet görünüm</CardDescription> <CardDescription>Özet görünüm</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground"> <CardContent className="flex h-48 flex-col justify-center space-y-3 text-sm text-muted-foreground">
<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">
@@ -136,33 +272,50 @@ export function HomePage() {
<CardHeader className="flex items-center justify-between"> <CardHeader className="flex items-center justify-between">
<div> <div>
<CardTitle>Etkinlik Akışı</CardTitle> <CardTitle>Etkinlik Akışı</CardTitle>
<CardDescription>Son 10 job çalıştırması</CardDescription> <CardDescription>Son 10 aktivite</CardDescription>
</div> </div>
<div className="text-xs text-muted-foreground flex items-center gap-1"> <div className="text-xs text-muted-foreground flex items-center gap-1">
<FontAwesomeIcon icon={faListCheck} className="h-3.5 w-3.5" /> <FontAwesomeIcon icon={faListCheck} className="h-3.5 w-3.5" />
{mergedRuns.length ?? 0} kayıt {activityItems.length ?? 0} kayıt
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{loading && <div className="text-sm text-muted-foreground">Yükleniyor...</div>} {loading && <div className="text-sm text-muted-foreground">Yükleniyor...</div>}
{error && <div className="text-sm text-destructive">{error}</div>} {error && <div className="text-sm text-destructive">{error}</div>}
{!loading && mergedRuns.length === 0 && ( {!loading && activityItems.length === 0 && (
<div className="text-sm text-muted-foreground">Henüz çalıştırma yok.</div> <div className="text-sm text-muted-foreground">Henüz çalıştırma yok.</div>
)} )}
{!loading && {!loading &&
mergedRuns.map((run) => ( activityItems.map((run) => (
<button <button
key={run._id} key={run.id}
type="button" type="button"
onClick={() => navigate(`/jobs/${run.job._id}`)} onClick={() => navigate(run.link)}
className="flex w-full items-center justify-between rounded-md border border-border bg-muted/40 px-3 py-2 text-left transition hover:bg-muted" className="flex w-full items-center justify-between rounded-md border border-border bg-muted/40 px-3 py-2 text-left transition hover:bg-muted"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<RepoIcon repoUrl={run.job.repoUrl} /> <RepoIcon repoUrl={run.repoUrl} />
<div> <div>
<div className="text-sm font-semibold text-foreground">{run.job.name}</div> <div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
<span>{run.title}</span>
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${
run.type === "test"
? "border-sky-200 bg-sky-100 text-sky-700"
: "border-amber-200 bg-amber-100 text-amber-800"
}`}
>
<FontAwesomeIcon
icon={run.type === "test" ? faFlaskVial : faRocket}
className="h-3 w-3"
/>
{run.type === "test" ? "Test" : "Deploy"}
</span>
</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{new Date(run.startedAt).toLocaleString()} · Süre: {formatDuration(run.durationMs)} {new Date(run.startedAt).toLocaleString()} · Süre:{" "}
{formatDuration(run.durationMs)}
{run.type === "deploy" && run.message ? ` · ${run.message}` : ""}
</div> </div>
</div> </div>
</div> </div>

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

@@ -0,0 +1,282 @@
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBroom, faCopy, faEye, faEyeSlash, faFloppyDisk, faRotate } from "@fortawesome/free-solid-svg-icons";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { 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() {
const [settings, setSettings] = useState<SettingsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [rotatingToken, setRotatingToken] = useState(false);
const [rotatingSecret, setRotatingSecret] = useState(false);
const [showToken, setShowToken] = 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(() => {
fetchSettings()
.then((data) => {
setSettings(data);
if (data.cleanupIntervalValue) {
setCleanupValue(String(data.cleanupIntervalValue));
}
if (data.cleanupIntervalUnit) {
setCleanupUnit(data.cleanupIntervalUnit);
}
})
.catch(() => toast.error("Settings yüklenemedi"))
.finally(() => setLoading(false));
}, []);
const handleCopy = async (value: string, label: string) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
if (!ok) throw new Error("copy failed");
}
toast.success(`${label} kopyalandı`);
} catch {
toast.error(`${label} kopyalanamadı`);
}
};
const handleRotateToken = async () => {
const ok = window.confirm(
"API Token yenilenecek. Gitea webhook ayarları güncellenmezse mevcut deployment'lar tetiklenmez. Devam etmek istiyor musun?"
);
if (!ok) return;
setRotatingToken(true);
try {
const data = await rotateWebhookToken();
setSettings((prev) => (prev ? { ...prev, webhookToken: data.webhookToken } : data));
toast.success("API token yenilendi");
} catch {
toast.error("API token yenilenemedi");
} finally {
setRotatingToken(false);
}
};
const handleRotateSecret = async () => {
const ok = window.confirm(
"Webhook Secret yenilenecek. Gitea webhook ayarları güncellenmezse imza doğrulaması başarısız olur. Devam etmek istiyor musun?"
);
if (!ok) return;
setRotatingSecret(true);
try {
const data = await rotateWebhookSecret();
setSettings((prev) => (prev ? { ...prev, webhookSecret: data.webhookSecret } : data));
toast.success("Webhook secret yenilendi");
} catch {
toast.error("Webhook secret yenilenemedi");
} finally {
setRotatingSecret(false);
}
};
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) {
return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
Settings yükleniyor...
</div>
);
}
if (!settings) {
return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
Settings bulunamadı.
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-foreground">Settings</h2>
<p className="text-sm text-muted-foreground">
Gitea webhook çağrıları için API token ve secret bilgileri.
</p>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>API Token</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRotateToken} disabled={rotatingToken}>
<FontAwesomeIcon icon={faRotate} className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-3 rounded-md bg-muted px-3 py-2 text-sm">
<code className="break-all text-foreground/80">
{showToken ? settings.webhookToken : "•".repeat(settings.webhookToken.length)}
</code>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowToken((prev) => !prev)}
title={showToken ? "Gizle" : "Göster"}
>
<FontAwesomeIcon icon={showToken ? faEyeSlash : faEye} className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(settings.webhookToken, "API token")}
title="Kopyala"
>
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Webhook Secret</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRotateSecret} disabled={rotatingSecret}>
<FontAwesomeIcon icon={faRotate} className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-3 rounded-md bg-muted px-3 py-2 text-sm">
<code className="break-all text-foreground/80">
{showSecret ? settings.webhookSecret : "•".repeat(settings.webhookSecret.length)}
</code>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowSecret((prev) => !prev)}
title={showSecret ? "Gizle" : "Göster"}
>
<FontAwesomeIcon icon={showSecret ? faEyeSlash : faEye} className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(settings.webhookSecret, "Webhook secret")}
title="Kopyala"
>
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</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>
);
}

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"]
}); });