Projeleri otomatik deployment etme özelliği eklendi.
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
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
|
||||
41
README.md
41
README.md
@@ -28,6 +28,13 @@
|
||||
- **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
|
||||
- **Root Tarama**: `DEPLOYMENTS_ROOT_HOST` altında compose dosyası olan projeleri otomatik bulma
|
||||
- **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
|
||||
@@ -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
|
||||
@@ -206,6 +221,19 @@ docker compose up -d --build
|
||||
- **Log Akışı**: Test çıktılarını canlı izleme
|
||||
- **Manuel Çalıştırma**: Job'u anında tetikleme
|
||||
|
||||
### Deployment Yönetimi
|
||||
1. **Deployments** sayfasına gidin
|
||||
2. **New Deployment** ile root altında taranan projeyi seçin
|
||||
3. Repo URL + Branch + Compose dosyasını girin
|
||||
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
|
||||
|
||||
#### Token Yönetimi
|
||||
@@ -223,6 +251,8 @@ 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
|
||||
- **Deployment API'leri**: `/deployments`, `/deployments/:id`, `/deployments/scan`, `/deployments/branches`
|
||||
- **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ı
|
||||
|
||||
@@ -2,8 +2,8 @@ PORT=4000
|
||||
# Prod için zorunlu Mongo bağlantısı
|
||||
# Örnek: mongodb://<APP_USER>:<APP_PASS>@<HOST>:27017/wisecoltci?authSource=wisecoltci
|
||||
MONGO_URI=mongodb://app:change-me@mongo-host:27017/wisecoltci?authSource=wisecoltci
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=supersecret
|
||||
JWT_SECRET=change-me
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
DEPLOYMENTS_ROOT_HOST=/home/wisecolt-dev/workspace
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,8 @@ export const config = {
|
||||
adminUsername: process.env.ADMIN_USERNAME || "admin",
|
||||
adminPassword: process.env.ADMIN_PASSWORD || "password",
|
||||
jwtSecret: process.env.JWT_SECRET || "changeme",
|
||||
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173"
|
||||
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173",
|
||||
deploymentsRoot: "/workspace"
|
||||
};
|
||||
|
||||
if (!config.jwtSecret) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
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 },
|
||||
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);
|
||||
193
backend/src/routes/deployments.ts
Normal file
193
backend/src/routes/deployments.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
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("/scan", async (req, res) => {
|
||||
authMiddleware(req, res, async () => {
|
||||
try {
|
||||
const candidates = await deploymentService.scanRoot();
|
||||
return res.json(candidates);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: "Root taraması yapılamadı" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/branches", async (req, res) => {
|
||||
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("/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, rootPath, repoUrl, branch, composeFile, port } = req.body;
|
||||
if (!name || !rootPath || !repoUrl || !branch || !composeFile) {
|
||||
return res.status(400).json({ message: "Tüm alanlar gerekli" });
|
||||
}
|
||||
try {
|
||||
const created = await deploymentService.createProject({
|
||||
name,
|
||||
rootPath,
|
||||
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 { config } from "../config/env.js";
|
||||
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"];
|
||||
|
||||
function normalizeRoot(rootPath: string) {
|
||||
return path.resolve(rootPath);
|
||||
}
|
||||
|
||||
function isWithinRoot(rootPath: string, targetPath: string) {
|
||||
const resolvedRoot = normalizeRoot(rootPath);
|
||||
const resolvedTarget = path.resolve(targetPath);
|
||||
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`);
|
||||
}
|
||||
|
||||
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 scanRoot() {
|
||||
const rootPath = normalizeRoot(config.deploymentsRoot);
|
||||
if (!fs.existsSync(rootPath)) {
|
||||
throw new Error("Deployments root bulunamadı");
|
||||
}
|
||||
const entries = await fs.promises.readdir(rootPath, { withFileTypes: true });
|
||||
const candidates = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith(".")) continue;
|
||||
const folderPath = path.join(rootPath, entry.name);
|
||||
const available = composeFileCandidates.filter((file) =>
|
||||
fs.existsSync(path.join(folderPath, file))
|
||||
);
|
||||
if (available.length === 0) continue;
|
||||
candidates.push({
|
||||
name: entry.name,
|
||||
rootPath: folderPath,
|
||||
composeFiles: available
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async listRemoteBranches(repoUrl: string) {
|
||||
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 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;
|
||||
rootPath: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composeFile: ComposeFile;
|
||||
port?: number;
|
||||
}) {
|
||||
const rootPath = path.resolve(input.rootPath);
|
||||
if (!isWithinRoot(config.deploymentsRoot, rootPath)) {
|
||||
throw new Error("Root path deployments root dışında");
|
||||
}
|
||||
if (!fs.existsSync(rootPath)) {
|
||||
throw new Error("Root path bulunamadı");
|
||||
}
|
||||
const composePath = path.join(rootPath, input.composeFile);
|
||||
if (!fs.existsSync(composePath)) {
|
||||
throw new Error("Compose dosyası bulunamadı");
|
||||
}
|
||||
|
||||
const existing = await DeploymentProject.findOne({ rootPath });
|
||||
if (existing) {
|
||||
throw new Error("Bu klasör zaten eklenmiş");
|
||||
}
|
||||
|
||||
let webhookToken = generateWebhookToken();
|
||||
while (await DeploymentProject.findOne({ webhookToken })) {
|
||||
webhookToken = generateWebhookToken();
|
||||
}
|
||||
|
||||
const env = deriveEnv(input.composeFile);
|
||||
return DeploymentProject.create({
|
||||
name: input.name,
|
||||
rootPath,
|
||||
repoUrl: input.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 composePath = path.join(project.rootPath, input.composeFile);
|
||||
if (!fs.existsSync(composePath)) {
|
||||
throw new Error("Compose dosyası bulunamadı");
|
||||
}
|
||||
const env = deriveEnv(input.composeFile);
|
||||
const updated = await DeploymentProject.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
name: input.name,
|
||||
repoUrl: input.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:
|
||||
- ./backend:/app
|
||||
- /app/node_modules
|
||||
- ${DEPLOYMENTS_ROOT_HOST}:/workspace
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
ports:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
115
frontend/src/api/deployments.ts
Normal file
115
frontend/src/api/deployments.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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 DeploymentCandidate {
|
||||
name: string;
|
||||
rootPath: string;
|
||||
composeFiles: ComposeFile[];
|
||||
}
|
||||
|
||||
export interface DeploymentInput {
|
||||
name: string;
|
||||
rootPath: 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 scanDeployments(): Promise<DeploymentCandidate[]> {
|
||||
const { data } = await apiClient.get("/deployments/scan");
|
||||
return data as DeploymentCandidate[];
|
||||
}
|
||||
|
||||
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: Omit<DeploymentInput, "rootPath">) {
|
||||
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;
|
||||
}
|
||||
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 { 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: "Jobs", 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>
|
||||
);
|
||||
}
|
||||
532
frontend/src/pages/DeploymentsPage.tsx
Normal file
532
frontend/src/pages/DeploymentsPage.tsx
Normal file
@@ -0,0 +1,532 @@
|
||||
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,
|
||||
DeploymentCandidate,
|
||||
DeploymentInput,
|
||||
DeploymentProject,
|
||||
fetchDeploymentBranches,
|
||||
fetchDeployments,
|
||||
runDeployment,
|
||||
scanDeployments,
|
||||
updateDeployment
|
||||
} from "../api/deployments";
|
||||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||
|
||||
type FormState = {
|
||||
_id?: string;
|
||||
name: string;
|
||||
rootPath: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composeFile: DeploymentInput["composeFile"];
|
||||
port: string;
|
||||
};
|
||||
|
||||
const defaultForm: FormState = {
|
||||
name: "",
|
||||
rootPath: "",
|
||||
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 [scanning, setScanning] = useState(false);
|
||||
const [candidates, setCandidates] = useState<DeploymentCandidate[]>([]);
|
||||
const [form, setForm] = useState<FormState>(defaultForm);
|
||||
const [pendingEditId, setPendingEditId] = useState<string | null>(null);
|
||||
const [branchOptions, setBranchOptions] = useState<string[]>([]);
|
||||
const [branchLoading, setBranchLoading] = useState(false);
|
||||
const [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({});
|
||||
|
||||
const isEdit = useMemo(() => !!form._id, [form._id]);
|
||||
const selectedCandidate = useMemo(
|
||||
() => candidates.find((c) => c.rootPath === form.rootPath),
|
||||
[candidates, form.rootPath]
|
||||
);
|
||||
|
||||
const loadDeployments = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchDeployments();
|
||||
setDeployments(data);
|
||||
} catch {
|
||||
toast.error("Deployment listesi alınamadı");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCandidates = async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
const data = await scanDeployments();
|
||||
setCandidates(data);
|
||||
} catch {
|
||||
toast.error("Root taraması yapılamadı");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDeployments();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const repoUrl = form.repoUrl.trim();
|
||||
if (!repoUrl) {
|
||||
setBranchOptions([]);
|
||||
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 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([]);
|
||||
setModalOpen(true);
|
||||
await loadCandidates();
|
||||
};
|
||||
|
||||
const handleEdit = (deployment: DeploymentProject) => {
|
||||
const { _id, name, rootPath, repoUrl, branch, composeFile, port } = deployment;
|
||||
setForm({
|
||||
_id,
|
||||
name,
|
||||
rootPath,
|
||||
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,
|
||||
rootPath: form.rootPath,
|
||||
repoUrl: form.repoUrl,
|
||||
branch: form.branch,
|
||||
composeFile: form.composeFile,
|
||||
port: form.port ? Number(form.port) : undefined
|
||||
};
|
||||
|
||||
if (!payload.name || !payload.rootPath || !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="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Proje Klasörü</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadCandidates}
|
||||
disabled={scanning}
|
||||
>
|
||||
{scanning ? "Taranıyor..." : "Yeniden Tara"}
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={form.rootPath}
|
||||
onValueChange={(value) => {
|
||||
const candidate = candidates.find((c) => c.rootPath === value);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
rootPath: value,
|
||||
name: candidate?.name || prev.name,
|
||||
composeFile: candidate?.composeFiles[0] || prev.composeFile
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Root altında proje seçin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidates.map((candidate) => (
|
||||
<SelectItem key={candidate.rootPath} value={candidate.rootPath}>
|
||||
{candidate.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{scanning
|
||||
? "Root dizin taranıyor..."
|
||||
: candidates.length === 0
|
||||
? "Root altında compose dosyası bulunan proje yok."
|
||||
: "Compose dosyası bulunan klasörleri listeler."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div 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>
|
||||
{(selectedCandidate?.composeFiles || ["docker-compose.yml", "docker-compose.dev.yml"]).map(
|
||||
(file) => (
|
||||
<SelectItem key={file} value={file}>
|
||||
{file}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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 { 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("Job 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,6 +121,35 @@ export function HomePage() {
|
||||
});
|
||||
}, [metrics, jobStreams]);
|
||||
|
||||
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 lastRunDuration = useMemo(() => formatDuration(mergedRuns[0]?.durationMs), [mergedRuns]);
|
||||
|
||||
return (
|
||||
@@ -78,14 +159,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
|
||||
</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 +177,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,7 +206,7 @@ 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">
|
||||
@@ -136,33 +231,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>
|
||||
|
||||
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