Compare commits
7 Commits
b701d50d4a
...
deployment
| Author | SHA1 | Date | |
|---|---|---|---|
| aa12881c4b | |||
| f8d22cc082 | |||
| a43042fac1 | |||
| 0ce8559f51 | |||
| 0961751f4d | |||
| dc8d0eef1b | |||
| e5fd3bd9d5 |
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# ---------------------------------- 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"
|
||||||
|
|
||||||
|
# Host üzerinde projelerin bulunduğu dizin (compose volume için, zorunludur)
|
||||||
|
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace
|
||||||
71
README.md
71
README.md
@@ -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 Gitea’da 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ı
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
PORT=4000
|
PORT=4000
|
||||||
# Prod için zorunlu Mongo bağlantısı
|
# Prod için zorunlu Mongo bağlantısı
|
||||||
# Örnek: mongodb://<APP_USER>:<APP_PASS>@<HOST>:27017/wisecoltci?authSource=wisecoltci
|
# Örnek: mongodb://mongo:27017/wisecoltci
|
||||||
MONGO_URI=mongodb://app:change-me@mongo-host:27017/wisecoltci?authSource=wisecoltci
|
MONGO_URI=mongodb://mongo:27017/wisecoltci
|
||||||
|
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=supersecret
|
ADMIN_PASSWORD=supersecret
|
||||||
JWT_SECRET=change-me
|
JWT_SECRET=change-me
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
|
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ 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";
|
||||||
@@ -18,7 +21,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,6 +35,9 @@ 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);
|
||||||
|
|
||||||
|
|||||||
49
backend/src/models/deploymentProject.ts
Normal file
49
backend/src/models/deploymentProject.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
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 },
|
||||||
|
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
|
||||||
|
);
|
||||||
34
backend/src/models/deploymentRun.ts
Normal file
34
backend/src/models/deploymentRun.ts
Normal 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
|
||||||
|
);
|
||||||
18
backend/src/models/settings.ts
Normal file
18
backend/src/models/settings.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import mongoose, { Schema, Document } from "mongoose";
|
||||||
|
|
||||||
|
export interface SettingsDocument extends Document {
|
||||||
|
webhookToken: string;
|
||||||
|
webhookSecret: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsSchema = new Schema<SettingsDocument>(
|
||||||
|
{
|
||||||
|
webhookToken: { type: String, required: true },
|
||||||
|
webhookSecret: { type: String, required: true }
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Settings = mongoose.model<SettingsDocument>("Settings", SettingsSchema);
|
||||||
197
backend/src/routes/deployments.ts
Normal file
197
backend/src/routes/deployments.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import fs from "fs";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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("/metrics/summary", async (req, res) => {
|
||||||
|
authMiddleware(req, res, async () => {
|
||||||
|
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 () => {
|
||||||
|
const 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 } = 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
|
||||||
|
});
|
||||||
|
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 } = 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
|
||||||
|
});
|
||||||
|
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 deleted = await DeploymentProject.findByIdAndDelete(id);
|
||||||
|
if (!deleted) return res.status(404).json({ message: "Deployment bulunamadı" });
|
||||||
|
await DeploymentRun.deleteMany({ project: id });
|
||||||
|
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ı" });
|
||||||
|
deploymentService.runDeployment(id).catch(() => undefined);
|
||||||
|
return res.json({ queued: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
34
backend/src/routes/settings.ts
Normal file
34
backend/src/routes/settings.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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,
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
66
backend/src/routes/webhooks.ts
Normal file
66
backend/src/routes/webhooks.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Router, Request } from "express";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { deploymentService } 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ref.startsWith("refs/heads/") ? ref.replace("refs/heads/", "") : ref;
|
||||||
|
const commitMessage =
|
||||||
|
payload?.head_commit?.message || payload?.commits?.[payload.commits.length - 1]?.message;
|
||||||
|
|
||||||
|
const project = await deploymentService.findByWebhookToken(token);
|
||||||
|
if (!project) return res.status(404).json({ message: "Deployment bulunamadi" });
|
||||||
|
|
||||||
|
if (branch && branch !== project.branch) {
|
||||||
|
return res.json({ ignored: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
deploymentService
|
||||||
|
.runDeployment(project._id.toString(), commitMessage ? { message: commitMessage } : undefined)
|
||||||
|
.catch(() => undefined);
|
||||||
|
return res.json({ queued: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
366
backend/src/services/deploymentService.ts
Normal file
366
backend/src/services/deploymentService.ts
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import {
|
||||||
|
DeploymentProject,
|
||||||
|
DeploymentProjectDocument,
|
||||||
|
ComposeFile,
|
||||||
|
DeploymentEnv
|
||||||
|
} from "../models/deploymentProject.js";
|
||||||
|
import { DeploymentRun } from "../models/deploymentRun.js";
|
||||||
|
import { Settings } from "../models/settings.js";
|
||||||
|
|
||||||
|
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
|
||||||
|
|
||||||
|
const deploymentsRoot = "/workspace/deployments";
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
throw new Error("Repo klasoru git olmayan dosyalar iceriyor");
|
||||||
|
}
|
||||||
|
onData(`Repo klonlanıyor: ${project.repoUrl}`);
|
||||||
|
await runCommand(`git clone --branch ${project.branch} ${project.repoUrl} .`, repoDir, onData);
|
||||||
|
} 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();
|
||||||
|
|
||||||
|
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 ensureSettings() {
|
||||||
|
const existing = await Settings.findOne();
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = await Settings.create({
|
||||||
|
webhookToken: generateApiToken(),
|
||||||
|
webhookSecret: generateSecret()
|
||||||
|
});
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotateToken() {
|
||||||
|
const settings = await this.ensureSettings();
|
||||||
|
settings.webhookToken = generateApiToken();
|
||||||
|
await settings.save();
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotateSecret() {
|
||||||
|
const settings = await this.ensureSettings();
|
||||||
|
settings.webhookSecret = generateSecret();
|
||||||
|
await settings.save();
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProject(input: {
|
||||||
|
name: string;
|
||||||
|
repoUrl: string;
|
||||||
|
branch: string;
|
||||||
|
composeFile: ComposeFile;
|
||||||
|
port?: number;
|
||||||
|
}) {
|
||||||
|
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);
|
||||||
|
return DeploymentProject.create({
|
||||||
|
name: input.name,
|
||||||
|
rootPath,
|
||||||
|
repoUrl,
|
||||||
|
branch: input.branch,
|
||||||
|
composeFile: input.composeFile,
|
||||||
|
webhookToken,
|
||||||
|
env,
|
||||||
|
port: input.port
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProject(
|
||||||
|
id: string,
|
||||||
|
input: {
|
||||||
|
name: string;
|
||||||
|
repoUrl: string;
|
||||||
|
branch: string;
|
||||||
|
composeFile: ComposeFile;
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{ new: true, runValidators: true }
|
||||||
|
);
|
||||||
|
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 startedAt = Date.now();
|
||||||
|
const runLogs: string[] = [];
|
||||||
|
const pushLog = (line: string) => {
|
||||||
|
runLogs.push(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runDoc = await DeploymentRun.create({
|
||||||
|
project: projectId,
|
||||||
|
status: "running",
|
||||||
|
startedAt: new Date(),
|
||||||
|
message: options?.message
|
||||||
|
});
|
||||||
|
|
||||||
|
await DeploymentProject.findByIdAndUpdate(projectId, {
|
||||||
|
lastStatus: "running",
|
||||||
|
lastMessage: options?.message || "Deploy başlıyor..."
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureRepo(project, (line) => pushLog(line));
|
||||||
|
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: options?.message || "Başarılı"
|
||||||
|
});
|
||||||
|
await DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
||||||
|
status: "success",
|
||||||
|
finishedAt: new Date(),
|
||||||
|
durationMs: duration,
|
||||||
|
logs: runLogs,
|
||||||
|
message: options?.message
|
||||||
|
});
|
||||||
|
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 DeploymentRun.findByIdAndUpdate(runDoc._id, {
|
||||||
|
status: "failed",
|
||||||
|
finishedAt: new Date(),
|
||||||
|
durationMs: duration,
|
||||||
|
logs: runLogs,
|
||||||
|
message: options?.message
|
||||||
|
});
|
||||||
|
pushLog(`Hata: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
this.running.delete(projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByWebhookToken(token: string) {
|
||||||
|
return DeploymentProject.findOne({ webhookToken: token });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deploymentService = new DeploymentService();
|
||||||
|
export { generateApiToken, generateSecret };
|
||||||
@@ -13,6 +13,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
- ${DEPLOYMENTS_ROOT_HOST}:/workspace
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
command: npm run build && npm start
|
command: npm run build && npm start
|
||||||
|
volumes:
|
||||||
|
- ${DEPLOYMENTS_ROOT_HOST}:/workspace
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
VITE_API_URL=http://localhost:4000
|
VITE_API_URL=http://localhost:4000/api
|
||||||
# Prod için izin verilecek host(lar), virgülle ayırabilirsiniz. Örn:
|
# 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
|
# ALLOWED_HOSTS=wisecolt-ci-frontend-ft2pzo-1c0eb3-188-245-185-248.traefik.me
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
113
frontend/src/api/deployments.ts
Normal file
113
frontend/src/api/deployments.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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): Promise<void> {
|
||||||
|
await apiClient.post(`/deployments/${id}/run`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
22
frontend/src/api/settings.ts
Normal file
22
frontend/src/api/settings.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface SettingsResponse {
|
||||||
|
webhookToken: string;
|
||||||
|
webhookSecret: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
],
|
],
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|||||||
221
frontend/src/pages/DeploymentDetailPage.tsx
Normal file
221
frontend/src/pages/DeploymentDetailPage.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faArrowLeft, faCloudArrowUp, faCopy, faHistory } 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 { JobStatusBadge } from "../components/JobStatusBadge";
|
||||||
|
import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
fetchDeployment(id)
|
||||||
|
.then((data) => {
|
||||||
|
setProject(data.project);
|
||||||
|
setRuns(data.runs);
|
||||||
|
})
|
||||||
|
.catch(() => toast.error("Deployment bulunamadı"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const webhookUrl = useMemo(() => {
|
||||||
|
if (!project) return "";
|
||||||
|
return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`;
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
|
const latestRun = runs[0];
|
||||||
|
|
||||||
|
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 {
|
||||||
|
await navigator.clipboard.writeText(webhookUrl);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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={() => navigate("/deployments", { state: { editDeploymentId: project._id } })}
|
||||||
|
>
|
||||||
|
Düzenle
|
||||||
|
</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={project.lastStatus} />
|
||||||
|
</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="space-y-3">
|
||||||
|
{runs.length === 0 && (
|
||||||
|
<div className="text-sm text-muted-foreground">Henüz deploy çalıştırılmadı.</div>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</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">
|
||||||
|
{latestRun?.logs?.length ? (
|
||||||
|
latestRun.logs.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
504
frontend/src/pages/DeploymentsPage.tsx
Normal file
504
frontend/src/pages/DeploymentsPage.tsx
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import {
|
||||||
|
faCloudArrowUp,
|
||||||
|
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 {
|
||||||
|
createDeployment,
|
||||||
|
deleteDeployment,
|
||||||
|
DeploymentInput,
|
||||||
|
DeploymentProject,
|
||||||
|
fetchDeploymentComposeFiles,
|
||||||
|
fetchDeploymentBranches,
|
||||||
|
fetchDeployments,
|
||||||
|
runDeployment,
|
||||||
|
updateDeployment
|
||||||
|
} from "../api/deployments";
|
||||||
|
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
_id?: string;
|
||||||
|
name: string;
|
||||||
|
repoUrl: string;
|
||||||
|
branch: string;
|
||||||
|
composeFile: DeploymentInput["composeFile"];
|
||||||
|
port: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultForm: FormState = {
|
||||||
|
name: "",
|
||||||
|
repoUrl: "",
|
||||||
|
branch: "main",
|
||||||
|
composeFile: "docker-compose.yml",
|
||||||
|
port: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DeploymentsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
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 [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;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
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 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([]);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (deployment: DeploymentProject) => {
|
||||||
|
const { _id, name, repoUrl, branch, composeFile, port } = deployment;
|
||||||
|
setForm({
|
||||||
|
_id,
|
||||||
|
name,
|
||||||
|
repoUrl,
|
||||||
|
branch,
|
||||||
|
composeFile,
|
||||||
|
port: port ? String(port) : ""
|
||||||
|
});
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d)));
|
||||||
|
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 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={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();
|
||||||
|
handleEdit(deployment);
|
||||||
|
}}
|
||||||
|
title="Düzenle"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faRotate} 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="w-full max-w-lg overflow-hidden 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="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="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4">
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="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="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="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>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,10 +13,11 @@ 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";
|
||||||
|
|
||||||
function formatDuration(ms?: number) {
|
function formatDuration(ms?: number) {
|
||||||
if (!ms || Number.isNaN(ms)) return "-";
|
if (!ms || Number.isNaN(ms)) return "-";
|
||||||
@@ -29,28 +30,79 @@ 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 navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchJobMetrics()
|
Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()])
|
||||||
.then(setMetrics)
|
.then(([jobResult, deployResult]) => {
|
||||||
.catch(() => setError("Metrikler alınamadı"))
|
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));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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);
|
||||||
|
});
|
||||||
|
return days.map((date) => ({
|
||||||
|
date,
|
||||||
|
"Test Başarılı": 0,
|
||||||
|
"Test Hatalı": 0,
|
||||||
|
"Deploy Başarılı": 0,
|
||||||
|
"Deploy Hatalı": 0
|
||||||
}));
|
}));
|
||||||
}, [metrics]);
|
}
|
||||||
|
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 +121,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 +176,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 +194,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 +223,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 +248,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
|
||||||
|
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>
|
||||||
|
<span>{run.title}</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>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function JobDetailPage() {
|
|||||||
checkUnit: data.job.checkUnit
|
checkUnit: data.job.checkUnit
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => setError("Job bulunamadı"))
|
.catch(() => setError("Test bulunamadı"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
@@ -174,14 +174,14 @@ export function JobDetailPage() {
|
|||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!job?._id) return;
|
if (!job?._id) return;
|
||||||
const ok = window.confirm("Bu job'ı silmek istediğinize emin misiniz?");
|
const ok = window.confirm("Bu testi silmek istediğinize emin misiniz?");
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await deleteJob(job._id);
|
await deleteJob(job._id);
|
||||||
toast.success("Job silindi");
|
toast.success("Test silindi");
|
||||||
navigate("/jobs", { replace: true });
|
navigate("/jobs", { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Job silinemedi");
|
toast.error("Test silinemedi");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ export function JobDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await updateJob(job._id, payload);
|
await updateJob(job._id, payload);
|
||||||
toast.success("Job güncellendi");
|
toast.success("Test güncellendi");
|
||||||
setJob((prev) =>
|
setJob((prev) =>
|
||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
@@ -281,8 +281,8 @@ export function JobDetailPage() {
|
|||||||
className="h-10 w-10 transition hover:bg-emerald-100"
|
className="h-10 w-10 transition hover:bg-emerald-100"
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
disabled={!job}
|
disabled={!job}
|
||||||
title="Job'ı düzenle"
|
title="Testi düzenle"
|
||||||
aria-label="Job'ı düzenle"
|
aria-label="Testi düzenle"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPen} className="h-4 w-4 text-foreground" />
|
<FontAwesomeIcon icon={faPen} className="h-4 w-4 text-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -292,8 +292,8 @@ export function JobDetailPage() {
|
|||||||
className="h-10 w-10 transition hover:bg-red-100"
|
className="h-10 w-10 transition hover:bg-red-100"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={!job}
|
disabled={!job}
|
||||||
title="Job'ı sil"
|
title="Testi sil"
|
||||||
aria-label="Job'ı sil"
|
aria-label="Testi sil"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-foreground" />
|
<FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -317,7 +317,7 @@ export function JobDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full items-center gap-2">
|
<div className="flex w-full items-center gap-2">
|
||||||
<CardTitle>{job?.name || "Job Detayı"}</CardTitle>
|
<CardTitle>{job?.name || "Test Detayı"}</CardTitle>
|
||||||
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-muted/50 px-3 py-1 text-xs font-semibold text-foreground">
|
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-muted/50 px-3 py-1 text-xs font-semibold text-foreground">
|
||||||
<FontAwesomeIcon icon={faRepeat} className="h-3.5 w-3.5 text-foreground/80" />
|
<FontAwesomeIcon icon={faRepeat} className="h-3.5 w-3.5 text-foreground/80" />
|
||||||
{runCount} test
|
{runCount} test
|
||||||
@@ -368,7 +368,7 @@ export function JobDetailPage() {
|
|||||||
<div className="w-full max-w-lg rounded-lg border border-border bg-card card-shadow">
|
<div className="w-full max-w-lg rounded-lg border border-border bg-card card-shadow">
|
||||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-lg font-semibold text-foreground">Job Güncelle</div>
|
<div className="text-lg font-semibold text-foreground">Test Güncelle</div>
|
||||||
<div className="text-sm text-muted-foreground">Değişiklikler kaydedildiğinde test yeniden tetiklenecek.</div>
|
<div className="text-sm text-muted-foreground">Değişiklikler kaydedildiğinde test yeniden tetiklenecek.</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" onClick={() => setEditOpen(false)}>
|
<Button variant="ghost" size="icon" onClick={() => setEditOpen(false)}>
|
||||||
@@ -377,7 +377,7 @@ export function JobDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-5 py-4">
|
<div className="space-y-4 px-5 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Job Name</Label>
|
<Label htmlFor="name">Test Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function JobsPage() {
|
|||||||
const data = await fetchJobs();
|
const data = await fetchJobs();
|
||||||
setJobs(data);
|
setJobs(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Jobs alınamadı");
|
toast.error("Testler alınamadı");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -145,11 +145,11 @@ export function JobsPage() {
|
|||||||
j._id === updated._id ? { ...updated, runCount: j.runCount ?? updated.runCount } : j
|
j._id === updated._id ? { ...updated, runCount: j.runCount ?? updated.runCount } : j
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
toast.success("Job güncellendi");
|
toast.success("Test güncellendi");
|
||||||
} else {
|
} else {
|
||||||
const created = await createJob(payload);
|
const created = await createJob(payload);
|
||||||
setJobs((prev) => [{ ...created, runCount: created.runCount ?? 0 }, ...prev]);
|
setJobs((prev) => [{ ...created, runCount: created.runCount ?? 0 }, ...prev]);
|
||||||
toast.success("Job oluşturuldu");
|
toast.success("Test oluşturuldu");
|
||||||
}
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -163,9 +163,9 @@ export function JobsPage() {
|
|||||||
setRunningId(id);
|
setRunningId(id);
|
||||||
try {
|
try {
|
||||||
await runJob(id);
|
await runJob(id);
|
||||||
toast.success("Job çalıştırılıyor");
|
toast.success("Test çalıştırılıyor");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Job çalıştırılamadı");
|
toast.error("Test çalıştırılamadı");
|
||||||
} finally {
|
} finally {
|
||||||
setRunningId(null);
|
setRunningId(null);
|
||||||
}
|
}
|
||||||
@@ -185,18 +185,18 @@ export function JobsPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-foreground">Jobs</h2>
|
<h2 className="text-xl font-semibold text-foreground">Tests</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleOpenNew} className="gap-2">
|
<Button onClick={handleOpenNew} className="gap-2">
|
||||||
<FontAwesomeIcon icon={faPlus} className="h-4 w-4" />
|
<FontAwesomeIcon icon={faPlus} className="h-4 w-4" />
|
||||||
New Job
|
New Test
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||||
Jobs yükleniyor...
|
Testler yükleniyor...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loading && jobs.length === 0 && (
|
{!loading && jobs.length === 0 && (
|
||||||
@@ -280,9 +280,9 @@ export function JobsPage() {
|
|||||||
<div className="w-full max-w-lg rounded-lg border border-border bg-card card-shadow">
|
<div className="w-full max-w-lg rounded-lg border border-border bg-card card-shadow">
|
||||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-lg font-semibold text-foreground">{isEdit ? "Job Güncelle" : "Yeni Job"}</div>
|
<div className="text-lg font-semibold text-foreground">{isEdit ? "Test Güncelle" : "Yeni Test"}</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Detayları girin, Jobs listesi canlı olarak güncellenecek.
|
Detayları girin, Tests listesi canlı olarak güncellenecek.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" onClick={handleClose}>
|
<Button variant="ghost" size="icon" onClick={handleClose}>
|
||||||
@@ -291,7 +291,7 @@ export function JobsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-5 py-4">
|
<div className="space-y-4 px-5 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Job Name</Label>
|
<Label htmlFor="name">Test Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
|
|||||||
165
frontend/src/pages/SettingsPage.tsx
Normal file
165
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faCopy, faEye, faEyeSlash, 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 { fetchSettings, rotateWebhookSecret, rotateWebhookToken, 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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings()
|
||||||
|
.then((data) => setSettings(data))
|
||||||
|
.catch(() => toast.error("Settings yüklenemedi"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = async (value: string, label: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user