Compare commits

..

7 Commits

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

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

BREAKING CHANGE: Root dizin tarama özelliği ve `rootPath` alanı kaldırıldı.
Artık deploymentlar sadece repo URL ile oluşturulabiliyor.
2026-01-19 12:54:33 +03:00
a43042fac1 feat(ui): birleşik metrik hesaplaması ekle
İş ve deployment istatistiklerini birleştirerek toplam koşu sayısı
ve başarı oranı göstergelerini güncelle. Son çalışma süresi
hesaplamasını activityItems kullanacak şekilde düzelt.
2026-01-18 17:28:10 +03:00
0ce8559f51 docs(env): MongoDB bağlantı örneğini güncelle 2026-01-18 17:16:30 +03:00
0961751f4d refactor(ui,docs): Job terimini Test olarak güncelle
UI ve dokümantasyon boyunca "Job" terimleri "Test" olarak değiştirildi.
Bu değişiklik, uygulamanın terminolojisini tutarlı hale getirmek
ve kullanıcı arayüzünde daha doğru bir isimlendirme sağlamak için
yapıldı. Tüm etiketler, başlıklar, bildirimler ve dokümantasyon
güncellendi.
2026-01-18 16:42:36 +03:00
dc8d0eef1b docs: deployment özelliklerini dokümantasyona ekle 2026-01-18 16:29:47 +03:00
e5fd3bd9d5 feat(deployments): docker tabanlı proje yönetim ve otomatik deploy sistemi ekle
Docker Compose projeleri için tam kapsamlı yönetim paneli ve otomatik deployment altyapısı eklendi.

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

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

12
.env.example Normal file
View 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

View File

@@ -21,17 +21,24 @@
- **Korumalı Endpoint'ler**: JWT middleware ile korunan API endpoint'leri
- **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
- **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
- **Test Sonuçları**: Başarılı/başarısız sonuçların kaydedilmesi
- **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
- **WebSocket Bağlantısı**: Socket.io ile sürekli iletişim
- **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ü
### 🎨 Modern Arayüz
@@ -92,10 +99,15 @@ wisecolt-ci/
│ │ ├── 📁 middleware/ # Express middleware'leri
│ │ │ └── authMiddleware.ts
│ │ ├── 📁 models/ # MongoDB modelleri
│ │ │ ── job.ts
│ │ │ ── job.ts
│ │ │ ├── deploymentProject.ts
│ │ │ ├── deploymentRun.ts
│ │ │ └── settings.ts
│ │ ├── 📁 routes/ # API route'ları
│ │ │ ├── auth.ts
│ │ │ ── jobs.ts
│ │ │ ── jobs.ts
│ │ │ ├── deployments.ts
│ │ │ └── webhooks.ts
│ │ ├── 📁 services/ # İş mantığı katmanı
│ │ │ └── jobService.ts
│ │ └── 📄 index.ts # Ana sunucu dosyası
@@ -120,7 +132,10 @@ wisecolt-ci/
│ │ ├── 📁 pages/ # Sayfa bileşenleri
│ │ │ ├── HomePage.tsx
│ │ │ ├── JobsPage.tsx
│ │ │ ── JobDetailPage.tsx
│ │ │ ── JobDetailPage.tsx
│ │ │ ├── DeploymentsPage.tsx
│ │ │ ├── DeploymentDetailPage.tsx
│ │ │ └── SettingsPage.tsx
│ │ ├── 📁 providers/ # React Context Provider'lar
│ │ │ ├── auth-provider.tsx
│ │ │ ├── socket-provider.tsx
@@ -187,24 +202,37 @@ docker compose up -d --build
- **Şifre**: `supersecret`
3. Giriş yap butonuna tıklayın
### Job Yönetimi
### Test Yönetimi
#### Yeni Job Oluşturma
1. **Dashboard** menüsünden **Jobs** sayfasına gidin
2. **Yeni Job** butonuna tıklayın
3. Job bilgilerini girin:
- **Job Adı**: Tanımlayıcı bir isim
#### Yeni Test Oluşturma
1. **Dashboard** menüsünden **Tests** sayfasına gidin
2. **Yeni Test** butonuna tıklayın
3. Test bilgilerini girin:
- **Test Adı**: Tanımlayıcı bir isim
- **Repository URL**: GitHub repository adresi
- **Test Komutu**: Çalıştırılacak komut (örn: `npm test`)
- **Kontrol Aralığı**: Test sıklığı (dakika/saat/gün)
- **Kontrol Değeri**: Sayısal değer
4. Kaydet butonuna tıklayın
#### Job İzleme
- **Jobs Listesi**: Tüm job'ların durumunu gösterir
#### Test İzleme
- **Tests Listesi**: Tüm test'lerin durumunu gösterir
- **Real-time Durum**: Socket.io ile anlık güncellemeler
- **Log Akışı**: Test çıktılarını canlı izleme
- **Manuel Çalıştırma**: Job'u anında tetikleme
- **Manuel Çalıştırma**: Test'i anında tetikleme
### Deployment Yönetimi
1. **Deployments** sayfasına gidin
2. **New Deployment** ile Repo URL girin
3. Branch ve Compose dosyasını seçin
4. Kaydettikten sonra **Webhook URL**i Giteada web istemci olarak tanımlayın
#### Webhook Ayarları (Gitea)
- **Hedef URL**: `https://<domain>/api/deployments/webhook/<token>`
- **Yetkilendirme Başlığı**: `Bearer <API_TOKEN>`
- **Gizli**: `WEBHOOK_SECRET`
- **HTTP Yöntemi**: `POST`
- **İçerik Türü**: `application/json`
### Authentication
@@ -222,7 +250,9 @@ docker compose up -d --build
### 📖 API Referansı
- **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
- **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ı
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)
VITE_API_URL=http://localhost:4000 # Backend API adresi
VITE_API_URL=http://localhost:4000/api # Backend API adresi
```
### Port Yapılandırması
@@ -360,12 +393,12 @@ docker compose logs mongo
### Mevcut Durum (v1.0)
- ✅ Temel CI/CD platformu
- ✅ Real-time job yönetimi
- ✅ Real-time test yönetimi
- ✅ Modern web arayüzü
- ✅ Konteyner orkestrasyonu
### 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
- 📊 **Dashboard İstatistikleri**: Performans ve kullanım metrikleri
- 🛡️ **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ı
### E-post Listesi
- 📊 **Dashboard İstatistikleri**: Job performans grafikleri
- 📊 **Dashboard İstatistikleri**: Test performans grafikleri
- 🔔 **Bildirim Kanalları**: Slack, Discord, Teams entegrasyonu
- 🔄 **Pipeline Integration**: GitHub Actions, GitLab CI entegrasyonu
- 🏗️ **Template System**: Hazır proje şablonları

View File

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

View File

@@ -3,7 +3,7 @@ FROM node:20-alpine
WORKDIR /app
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 src ./src

View File

@@ -5,6 +5,9 @@ import mongoose from "mongoose";
import { Server } from "socket.io";
import authRoutes from "./routes/auth.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 jwt from "jsonwebtoken";
import { jobService } from "./services/jobService.js";
@@ -18,7 +21,13 @@ app.use(
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) => {
res.json({ status: "ok" });
@@ -26,6 +35,9 @@ app.get("/health", (_req, res) => {
app.use("/api/auth", authRoutes);
app.use("/api/jobs", jobsRoutes);
app.use("/", webhookRoutes);
app.use("/api/deployments", deploymentsRoutes);
app.use("/api/settings", settingsRoutes);
const server = http.createServer(app);

View 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
);

View File

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

View File

@@ -0,0 +1,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);

View 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;

View 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;

View 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;

View 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 };

View File

@@ -13,6 +13,8 @@ services:
volumes:
- ./backend:/app
- /app/node_modules
- ${DEPLOYMENTS_ROOT_HOST}:/workspace
- /var/run/docker.sock:/var/run/docker.sock
env_file:
- ./backend/.env
ports:

View File

@@ -2,6 +2,9 @@ services:
backend:
build: ./backend
command: npm run build && npm start
volumes:
- ${DEPLOYMENTS_ROOT_HOST}:/workspace
- /var/run/docker.sock:/var/run/docker.sock
env_file:
- ./backend/.env
ports:

View File

@@ -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:
# ALLOWED_HOSTS=wisecolt-ci-frontend-ft2pzo-1c0eb3-188-245-185-248.traefik.me

View File

@@ -5,6 +5,9 @@ import { DashboardLayout } from "./components/DashboardLayout";
import { HomePage } from "./pages/HomePage";
import { JobsPage } from "./pages/JobsPage";
import { JobDetailPage } from "./pages/JobDetailPage";
import { DeploymentsPage } from "./pages/DeploymentsPage";
import { DeploymentDetailPage } from "./pages/DeploymentDetailPage";
import { SettingsPage } from "./pages/SettingsPage";
function App() {
return (
@@ -15,6 +18,9 @@ function App() {
<Route path="/home" element={<HomePage />} />
<Route path="/jobs" element={<JobsPage />} />
<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>
</Route>

View 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;
}

View 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;
}

View File

@@ -1,7 +1,14 @@
import React, { useMemo, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
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 { ThemeToggle } from "./ThemeToggle";
import { useAuth } from "../providers/auth-provider";
@@ -15,7 +22,9 @@ export function DashboardLayout() {
const navigation = useMemo(
() => [
{ label: "Home", to: "/home", icon: faHouse },
{ label: "Jobs", to: "/jobs", icon: faFlaskVial }
{ label: "Tests", to: "/jobs", icon: faFlaskVial },
{ label: "Deployments", to: "/deployments", icon: faRocket },
{ label: "Settings", to: "/settings", icon: faGear }
],
[]
);

View File

@@ -0,0 +1,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>
);
}

View 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>
)}
</>
);
}

View File

@@ -13,10 +13,11 @@ import {
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { useLiveData } from "../providers/live-provider";
import { fetchJobMetrics, JobMetrics } from "../api/jobs";
import { fetchDeploymentMetrics, DeploymentMetrics, DeploymentRunWithProject } from "../api/deployments";
import { JobStatusBadge } from "../components/JobStatusBadge";
import { RepoIcon } from "../components/RepoIcon";
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) {
if (!ms || Number.isNaN(ms)) return "-";
@@ -29,28 +30,79 @@ function formatDuration(ms?: number) {
return `${hours}sa ${minutes % 60}dk`;
}
function toYmd(date: Date) {
return date.toISOString().slice(0, 10);
}
export function HomePage() {
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 [error, setError] = useState<string | null>(null);
const { jobStreams } = useLiveData();
const navigate = useNavigate();
useEffect(() => {
fetchJobMetrics()
.then(setMetrics)
.catch(() => setError("Metrikler alınamadı"))
Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()])
.then(([jobResult, deployResult]) => {
if (jobResult.status === "fulfilled") {
setMetrics(jobResult.value);
} else {
setMetrics({
dailyStats: [],
recentRuns: [],
totals: { successRate: 0, totalRuns: 0 }
});
setError("Test metrikleri alınamadı");
}
if (deployResult.status === "fulfilled") {
setDeploymentMetrics(deployResult.value);
setDeployRuns(deployResult.value.recentRuns || []);
} else {
setDeploymentMetrics({ dailyStats: [], recentRuns: [] });
}
})
.finally(() => setLoading(false));
}, []);
const chartData = useMemo(() => {
if (!metrics) return [];
return metrics.dailyStats.map((d) => ({
date: d._id,
Başarılı: d.success,
Hatalı: d.failed
}));
}, [metrics]);
if (!metrics) {
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) => ({
date,
"Test Başarılı": 0,
"Test Hatalı": 0,
"Deploy Başarılı": 0,
"Deploy Hatalı": 0
}));
}
const deployMap = new Map((deploymentMetrics?.dailyStats || []).map((d) => [d._id, d]));
const jobMap = new Map(metrics.dailyStats.map((d) => [d._id, d]));
const days = Array.from({ length: 7 }).map((_, idx) => {
const date = new Date();
date.setDate(date.getDate() - (6 - idx));
return toYmd(date);
});
return days.map((date) => {
const job = jobMap.get(date);
const deploy = deployMap.get(date);
return {
date,
"Test Başarılı": job?.success || 0,
"Test Hatalı": job?.failed || 0,
"Deploy Başarılı": deploy?.success || 0,
"Deploy Hatalı": deploy?.failed || 0
};
});
}, [metrics, deploymentMetrics]);
const mergedRuns = useMemo(() => {
if (!metrics) return [];
@@ -69,7 +121,53 @@ export function HomePage() {
});
}, [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 (
<div className="grid gap-6">
@@ -78,14 +176,14 @@ export function HomePage() {
<CardHeader className="flex items-center justify-between">
<div>
<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 className="text-xs text-muted-foreground flex items-center gap-2">
<FontAwesomeIcon icon={faClockRotateLeft} className="h-3.5 w-3.5" />
{metrics?.totals.totalRuns ?? 0} toplam koşu
{combinedTotals.totalRuns} toplam koşu
</div>
</CardHeader>
<CardContent className="h-80 min-w-0">
<CardContent className="h-48 min-w-0">
{loading ? (
<div className="text-sm text-muted-foreground">Yükleniyor...</div>
) : chartData.length === 0 ? (
@@ -96,10 +194,24 @@ export function HomePage() {
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="date" />
<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 />
<Line type="monotone" dataKey="Başarılı" stroke="#10b981" strokeWidth={2} />
<Line type="monotone" dataKey="Hatalı" stroke="#ef4444" strokeWidth={2} />
<Line type="monotone" dataKey="Test Başarılı" stroke="#10b981" 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>
</ResponsiveContainer>
)}
@@ -111,17 +223,17 @@ export function HomePage() {
<CardTitle>Hızlı Metrikler</CardTitle>
<CardDescription>Özet görünüm</CardDescription>
</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">
<span>Başarı Oranı</span>
<span className="text-lg font-semibold text-foreground">
{metrics?.totals.successRate ?? 0}%
{combinedTotals.successRate}%
</span>
</div>
<div className="flex items-center justify-between">
<span>Toplam Çalıştırma</span>
<span className="text-lg font-semibold text-foreground">
{metrics?.totals.totalRuns ?? 0}
{combinedTotals.totalRuns}
</span>
</div>
<div className="flex items-center justify-between">
@@ -136,33 +248,50 @@ export function HomePage() {
<CardHeader className="flex items-center justify-between">
<div>
<CardTitle>Etkinlik Akışı</CardTitle>
<CardDescription>Son 10 job çalıştırması</CardDescription>
<CardDescription>Son 10 aktivite</CardDescription>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<FontAwesomeIcon icon={faListCheck} className="h-3.5 w-3.5" />
{mergedRuns.length ?? 0} kayıt
{activityItems.length ?? 0} kayıt
</div>
</CardHeader>
<CardContent className="space-y-3">
{loading && <div className="text-sm text-muted-foreground">Yükleniyor...</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>
)}
{!loading &&
mergedRuns.map((run) => (
activityItems.map((run) => (
<button
key={run._id}
key={run.id}
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"
>
<div className="flex items-center gap-3">
<RepoIcon repoUrl={run.job.repoUrl} />
<RepoIcon repoUrl={run.repoUrl} />
<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">
{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>

View File

@@ -79,7 +79,7 @@ export function JobDetailPage() {
checkUnit: data.job.checkUnit
});
})
.catch(() => setError("Job bulunamadı"))
.catch(() => setError("Test bulunamadı"))
.finally(() => setLoading(false));
}, [id]);
@@ -174,14 +174,14 @@ export function JobDetailPage() {
const handleDelete = async () => {
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;
try {
await deleteJob(job._id);
toast.success("Job silindi");
toast.success("Test silindi");
navigate("/jobs", { replace: true });
} catch (err) {
toast.error("Job silinemedi");
toast.error("Test silinemedi");
}
};
@@ -203,7 +203,7 @@ export function JobDetailPage() {
return;
}
await updateJob(job._id, payload);
toast.success("Job güncellendi");
toast.success("Test güncellendi");
setJob((prev) =>
prev
? {
@@ -281,8 +281,8 @@ export function JobDetailPage() {
className="h-10 w-10 transition hover:bg-emerald-100"
onClick={handleEdit}
disabled={!job}
title="Job'ı düzenle"
aria-label="Job'ı düzenle"
title="Testi düzenle"
aria-label="Testi düzenle"
>
<FontAwesomeIcon icon={faPen} className="h-4 w-4 text-foreground" />
</Button>
@@ -292,8 +292,8 @@ export function JobDetailPage() {
className="h-10 w-10 transition hover:bg-red-100"
onClick={handleDelete}
disabled={!job}
title="Job'ı sil"
aria-label="Job'ı sil"
title="Testi sil"
aria-label="Testi sil"
>
<FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-foreground" />
</Button>
@@ -317,7 +317,7 @@ export function JobDetailPage() {
</div>
)}
<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">
<FontAwesomeIcon icon={faRepeat} className="h-3.5 w-3.5 text-foreground/80" />
{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="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">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>
<Button variant="ghost" size="icon" onClick={() => setEditOpen(false)}>
@@ -377,7 +377,7 @@ export function JobDetailPage() {
</div>
<div className="space-y-4 px-5 py-4">
<div className="space-y-2">
<Label htmlFor="name">Job Name</Label>
<Label htmlFor="name">Test Name</Label>
<Input
id="name"
value={form.name}

View File

@@ -58,7 +58,7 @@ export function JobsPage() {
const data = await fetchJobs();
setJobs(data);
} catch (err) {
toast.error("Jobs alınamadı");
toast.error("Testler alınamadı");
} finally {
setLoading(false);
}
@@ -145,11 +145,11 @@ export function JobsPage() {
j._id === updated._id ? { ...updated, runCount: j.runCount ?? updated.runCount } : j
)
);
toast.success("Job güncellendi");
toast.success("Test güncellendi");
} else {
const created = await createJob(payload);
setJobs((prev) => [{ ...created, runCount: created.runCount ?? 0 }, ...prev]);
toast.success("Job oluşturuldu");
toast.success("Test oluşturuldu");
}
setModalOpen(false);
} catch (err) {
@@ -163,9 +163,9 @@ export function JobsPage() {
setRunningId(id);
try {
await runJob(id);
toast.success("Job çalıştırılıyor");
toast.success("Test çalıştırılıyor");
} catch {
toast.error("Job çalıştırılamadı");
toast.error("Test çalıştırılamadı");
} finally {
setRunningId(null);
}
@@ -185,18 +185,18 @@ export function JobsPage() {
<>
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-foreground">Jobs</h2>
<h2 className="text-xl font-semibold text-foreground">Tests</h2>
</div>
<Button onClick={handleOpenNew} className="gap-2">
<FontAwesomeIcon icon={faPlus} className="h-4 w-4" />
New Job
New Test
</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">
Jobs yükleniyor...
Testler yükleniyor...
</div>
)}
{!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="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 ? "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">
Detayları girin, Jobs listesi canlı olarak güncellenecek.
Detayları girin, Tests listesi canlı olarak güncellenecek.
</div>
</div>
<Button variant="ghost" size="icon" onClick={handleClose}>
@@ -291,7 +291,7 @@ export function JobsPage() {
</div>
<div className="space-y-4 px-5 py-4">
<div className="space-y-2">
<Label htmlFor="name">Job Name</Label>
<Label htmlFor="name">Test Name</Label>
<Input
id="name"
value={form.name}

View 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>
);
}