Compare commits

..

5 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
12 changed files with 203 additions and 213 deletions

View File

@@ -21,7 +21,7 @@
- **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
@@ -29,7 +29,7 @@
- **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
- **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ı
@@ -38,7 +38,7 @@
### ⚡ 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
@@ -202,29 +202,29 @@ 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 root altında taranan projeyi seçin
3. Repo URL + Branch + Compose dosyasını girin
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)
@@ -250,8 +250,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`
- **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ı
@@ -393,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
@@ -406,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,7 +1,7 @@
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

View File

@@ -8,8 +8,7 @@ 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",
deploymentsRoot: "/workspace"
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173"
};
if (!config.jwtSecret) {

View File

@@ -24,7 +24,7 @@ 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 },
repoUrl: { type: String, required: true, trim: true, unique: true, index: true },
branch: { type: String, required: true, trim: true },
composeFile: {
type: String,

View File

@@ -39,17 +39,6 @@ router.get("/:id/favicon", async (req, 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;
@@ -65,6 +54,22 @@ router.get("/branches", async (req, res) => {
});
});
router.get("/compose-files", async (req, res) => {
authMiddleware(req, res, async () => {
const repoUrl = req.query.repoUrl as string | undefined;
const branch = req.query.branch as string | undefined;
if (!repoUrl || !branch) {
return res.status(400).json({ message: "repoUrl ve branch gerekli" });
}
try {
const files = await deploymentService.listRemoteComposeFiles(repoUrl, branch);
return res.json({ files });
} catch (err) {
return res.status(400).json({ message: "Compose listesi alınamadı", error: (err as Error).message });
}
});
});
router.get("/metrics/summary", async (req, res) => {
authMiddleware(req, res, async () => {
const since = new Date();
@@ -123,14 +128,13 @@ router.get("/:id", async (req, res) => {
router.post("/", async (req, res) => {
authMiddleware(req, res, async () => {
const { name, rootPath, repoUrl, branch, composeFile, port } = req.body;
if (!name || !rootPath || !repoUrl || !branch || !composeFile) {
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,
rootPath,
repoUrl,
branch,
composeFile,

View File

@@ -2,7 +2,6 @@ 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,
@@ -14,14 +13,18 @@ import { Settings } from "../models/settings.js";
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
function normalizeRoot(rootPath: string) {
return path.resolve(rootPath);
const deploymentsRoot = "/workspace/deployments";
function slugify(value: string) {
return value
.toLowerCase()
.replace(/\.git$/i, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function isWithinRoot(rootPath: string, targetPath: string) {
const resolvedRoot = normalizeRoot(rootPath);
const resolvedTarget = path.resolve(targetPath);
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`);
function normalizeRepoUrl(value: string) {
return value.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
}
function generateWebhookToken() {
@@ -132,10 +135,7 @@ async function ensureRepo(project: DeploymentProjectDocument, onData: (line: str
}
}
async function runCompose(
project: DeploymentProjectDocument,
onData: (line: string) => void
) {
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ı");
@@ -153,31 +153,6 @@ async function runCompose(
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
@@ -190,6 +165,24 @@ class DeploymentService {
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;
@@ -216,27 +209,15 @@ class DeploymentService {
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ş");
const repoUrl = normalizeRepoUrl(input.repoUrl);
const existingRepo = await DeploymentProject.findOne({ repoUrl });
if (existingRepo) {
throw new Error("Bu repo zaten eklenmiş");
}
let webhookToken = generateWebhookToken();
@@ -244,11 +225,23 @@ class DeploymentService {
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: input.repoUrl,
repoUrl,
branch: input.branch,
composeFile: input.composeFile,
webhookToken,
@@ -269,16 +262,23 @@ class DeploymentService {
) {
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 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: input.repoUrl,
repoUrl,
branch: input.branch,
composeFile: input.composeFile,
env,

View File

@@ -54,15 +54,8 @@ export interface DeploymentMetrics {
recentRuns: DeploymentRunWithProject[];
}
export interface DeploymentCandidate {
name: string;
rootPath: string;
composeFiles: ComposeFile[];
}
export interface DeploymentInput {
name: string;
rootPath: string;
repoUrl: string;
branch: string;
composeFile: ComposeFile;
@@ -81,17 +74,12 @@ export async function fetchDeploymentBranches(repoUrl: string): Promise<string[]
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">) {
export async function updateDeployment(id: string, payload: DeploymentInput) {
const { data } = await apiClient.put(`/deployments/${id}`, payload);
return data as DeploymentProject;
}
@@ -113,3 +101,13 @@ 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

@@ -22,7 +22,7 @@ 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

@@ -16,13 +16,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import {
createDeployment,
deleteDeployment,
DeploymentCandidate,
DeploymentInput,
DeploymentProject,
fetchDeploymentComposeFiles,
fetchDeploymentBranches,
fetchDeployments,
runDeployment,
scanDeployments,
updateDeployment
} from "../api/deployments";
import { JobStatusBadge } from "../components/JobStatusBadge";
@@ -30,7 +29,6 @@ import { JobStatusBadge } from "../components/JobStatusBadge";
type FormState = {
_id?: string;
name: string;
rootPath: string;
repoUrl: string;
branch: string;
composeFile: DeploymentInput["composeFile"];
@@ -39,7 +37,6 @@ type FormState = {
const defaultForm: FormState = {
name: "",
rootPath: "",
repoUrl: "",
branch: "main",
composeFile: "docker-compose.yml",
@@ -54,19 +51,15 @@ export function DeploymentsPage() {
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 [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 selectedCandidate = useMemo(
() => candidates.find((c) => c.rootPath === form.rootPath),
[candidates, form.rootPath]
);
const loadDeployments = async () => {
setLoading(true);
@@ -80,18 +73,6 @@ export function DeploymentsPage() {
}
};
const loadCandidates = async () => {
setScanning(true);
try {
const data = await scanDeployments();
setCandidates(data);
} catch {
toast.error("Root taraması yapılamadı");
} finally {
setScanning(false);
}
};
useEffect(() => {
loadDeployments();
}, []);
@@ -100,6 +81,7 @@ export function DeploymentsPage() {
const repoUrl = form.repoUrl.trim();
if (!repoUrl) {
setBranchOptions([]);
setComposeOptions([]);
return;
}
const timer = setTimeout(async () => {
@@ -119,6 +101,30 @@ export function DeploymentsPage() {
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) {
@@ -139,16 +145,15 @@ export function DeploymentsPage() {
const handleOpenNew = async () => {
setForm(defaultForm);
setBranchOptions([]);
setComposeOptions([]);
setModalOpen(true);
await loadCandidates();
};
const handleEdit = (deployment: DeploymentProject) => {
const { _id, name, rootPath, repoUrl, branch, composeFile, port } = deployment;
const { _id, name, repoUrl, branch, composeFile, port } = deployment;
setForm({
_id,
name,
rootPath,
repoUrl,
branch,
composeFile,
@@ -166,14 +171,13 @@ export function DeploymentsPage() {
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) {
if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) {
toast.error("Tüm alanları doldurun");
setSaving(false);
return;
@@ -374,48 +378,8 @@ export function DeploymentsPage() {
<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 className="text-xs text-muted-foreground">
Repo URL girildiğinde branch ve compose dosyaları listelenir.
</div>
)}
@@ -491,15 +455,23 @@ export function DeploymentsPage() {
<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>
)
)}
{(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">

View File

@@ -54,7 +54,7 @@ export function HomePage() {
recentRuns: [],
totals: { successRate: 0, totalRuns: 0 }
});
setError("Job metrikleri alınamadı");
setError("Test metrikleri alınamadı");
}
if (deployResult.status === "fulfilled") {
@@ -150,7 +150,24 @@ export function HomePage() {
.slice(0, 10);
}, [mergedRuns, deployRuns]);
const lastRunDuration = useMemo(() => formatDuration(mergedRuns[0]?.durationMs), [mergedRuns]);
const combinedTotals = useMemo(() => {
const jobSuccess = metrics?.dailyStats.reduce((acc, d) => acc + (d.success || 0), 0) ?? 0;
const jobTotal = metrics?.dailyStats.reduce((acc, d) => acc + (d.total || 0), 0) ?? 0;
const deploySuccess =
deploymentMetrics?.dailyStats.reduce((acc, d) => acc + (d.success || 0), 0) ?? 0;
const deployTotal =
deploymentMetrics?.dailyStats.reduce((acc, d) => acc + (d.total || 0), 0) ?? 0;
const totalRuns = jobTotal + deployTotal;
const successRate = totalRuns
? Math.round(((jobSuccess + deploySuccess) / totalRuns) * 100)
: 0;
return { totalRuns, successRate };
}, [metrics, deploymentMetrics]);
const lastRunDuration = useMemo(() => {
const latest = activityItems[0];
return formatDuration(latest?.durationMs);
}, [activityItems]);
return (
<div className="grid gap-6">
@@ -163,7 +180,7 @@ export function HomePage() {
</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-48 min-w-0">
@@ -210,13 +227,13 @@ export function HomePage() {
<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">

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}