Compare commits
3 Commits
deployment
...
2393078933
| Author | SHA1 | Date | |
|---|---|---|---|
| 2393078933 | |||
| 2ad6431a28 | |||
| 2b053120cb |
@@ -29,7 +29,7 @@
|
||||
- **Log Akışı**: Gerçek zamanlı test loglarının izlenmesi
|
||||
|
||||
### 🚀 Deployment Yönetimi
|
||||
- **Repo Bazlı Kurulum**: Repo URL ile proje oluşturma ve deploy klasörünü otomatik oluşturma
|
||||
- **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ı
|
||||
@@ -223,8 +223,8 @@ docker compose up -d --build
|
||||
|
||||
### Deployment Yönetimi
|
||||
1. **Deployments** sayfasına gidin
|
||||
2. **New Deployment** ile Repo URL girin
|
||||
3. Branch ve Compose dosyasını seçin
|
||||
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)
|
||||
@@ -251,7 +251,7 @@ docker compose up -d --build
|
||||
### 📖 API Referansı
|
||||
- **Authentication API'leri**: `/auth/login`, `/auth/me`
|
||||
- **Test Yönetim API'leri**: CRUD operasyonları, manuel çalıştırma
|
||||
- **Deployment API'leri**: `/deployments`, `/deployments/:id`, `/deployments/branches`, `/deployments/compose-files`
|
||||
- **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ı
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, unique: true, index: true },
|
||||
repoUrl: { type: String, required: true, trim: true },
|
||||
branch: { type: String, required: true, trim: true },
|
||||
composeFile: {
|
||||
type: String,
|
||||
|
||||
@@ -39,6 +39,17 @@ 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;
|
||||
@@ -54,22 +65,6 @@ 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();
|
||||
@@ -128,13 +123,14 @@ router.get("/:id", async (req, res) => {
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
authMiddleware(req, res, async () => {
|
||||
const { name, repoUrl, branch, composeFile, port } = req.body;
|
||||
if (!name || !repoUrl || !branch || !composeFile) {
|
||||
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,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -13,18 +14,14 @@ import { Settings } from "../models/settings.js";
|
||||
|
||||
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
|
||||
|
||||
const deploymentsRoot = "/workspace/deployments";
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
function normalizeRoot(rootPath: string) {
|
||||
return path.resolve(rootPath);
|
||||
}
|
||||
|
||||
function normalizeRepoUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
|
||||
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() {
|
||||
@@ -135,7 +132,10 @@ 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,6 +153,31 @@ async function runCompose(project: DeploymentProjectDocument, onData: (line: str
|
||||
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
|
||||
@@ -165,24 +190,6 @@ 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;
|
||||
@@ -209,15 +216,27 @@ class DeploymentService {
|
||||
|
||||
async createProject(input: {
|
||||
name: string;
|
||||
rootPath: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composeFile: ComposeFile;
|
||||
port?: number;
|
||||
}) {
|
||||
const repoUrl = normalizeRepoUrl(input.repoUrl);
|
||||
const existingRepo = await DeploymentProject.findOne({ repoUrl });
|
||||
if (existingRepo) {
|
||||
throw new Error("Bu repo zaten eklenmiş");
|
||||
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();
|
||||
@@ -225,23 +244,11 @@ 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,
|
||||
repoUrl: input.repoUrl,
|
||||
branch: input.branch,
|
||||
composeFile: input.composeFile,
|
||||
webhookToken,
|
||||
@@ -262,23 +269,16 @@ class DeploymentService {
|
||||
) {
|
||||
const project = await DeploymentProject.findById(id);
|
||||
if (!project) return null;
|
||||
const repoUrl = normalizeRepoUrl(input.repoUrl);
|
||||
if (repoUrl !== project.repoUrl) {
|
||||
const existingRepo = await DeploymentProject.findOne({ repoUrl });
|
||||
if (existingRepo && existingRepo._id.toString() !== id) {
|
||||
throw new Error("Bu repo zaten eklenmiş");
|
||||
}
|
||||
}
|
||||
const available = await this.listRemoteComposeFiles(repoUrl, input.branch);
|
||||
if (!available.includes(input.composeFile)) {
|
||||
throw new Error("Compose dosyası repoda bulunamadı");
|
||||
const 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,
|
||||
repoUrl: input.repoUrl,
|
||||
branch: input.branch,
|
||||
composeFile: input.composeFile,
|
||||
env,
|
||||
|
||||
@@ -54,8 +54,15 @@ 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;
|
||||
@@ -74,12 +81,17 @@ 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: DeploymentInput) {
|
||||
export async function updateDeployment(id: string, payload: Omit<DeploymentInput, "rootPath">) {
|
||||
const { data } = await apiClient.put(`/deployments/${id}`, payload);
|
||||
return data as DeploymentProject;
|
||||
}
|
||||
@@ -101,13 +113,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -16,12 +16,13 @@ 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";
|
||||
@@ -29,6 +30,7 @@ import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||
type FormState = {
|
||||
_id?: string;
|
||||
name: string;
|
||||
rootPath: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composeFile: DeploymentInput["composeFile"];
|
||||
@@ -37,6 +39,7 @@ type FormState = {
|
||||
|
||||
const defaultForm: FormState = {
|
||||
name: "",
|
||||
rootPath: "",
|
||||
repoUrl: "",
|
||||
branch: "main",
|
||||
composeFile: "docker-compose.yml",
|
||||
@@ -51,15 +54,19 @@ 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);
|
||||
@@ -73,6 +80,18 @@ 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();
|
||||
}, []);
|
||||
@@ -81,7 +100,6 @@ export function DeploymentsPage() {
|
||||
const repoUrl = form.repoUrl.trim();
|
||||
if (!repoUrl) {
|
||||
setBranchOptions([]);
|
||||
setComposeOptions([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
@@ -101,30 +119,6 @@ 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) {
|
||||
@@ -145,15 +139,16 @@ export function DeploymentsPage() {
|
||||
const handleOpenNew = async () => {
|
||||
setForm(defaultForm);
|
||||
setBranchOptions([]);
|
||||
setComposeOptions([]);
|
||||
setModalOpen(true);
|
||||
await loadCandidates();
|
||||
};
|
||||
|
||||
const handleEdit = (deployment: DeploymentProject) => {
|
||||
const { _id, name, repoUrl, branch, composeFile, port } = deployment;
|
||||
const { _id, name, rootPath, repoUrl, branch, composeFile, port } = deployment;
|
||||
setForm({
|
||||
_id,
|
||||
name,
|
||||
rootPath,
|
||||
repoUrl,
|
||||
branch,
|
||||
composeFile,
|
||||
@@ -171,13 +166,14 @@ 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.repoUrl || !payload.branch || !payload.composeFile) {
|
||||
if (!payload.name || !payload.rootPath || !payload.repoUrl || !payload.branch || !payload.composeFile) {
|
||||
toast.error("Tüm alanları doldurun");
|
||||
setSaving(false);
|
||||
return;
|
||||
@@ -378,8 +374,48 @@ export function DeploymentsPage() {
|
||||
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4">
|
||||
{!isEdit && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Repo URL girildiğinde branch ve compose dosyaları listelenir.
|
||||
<div 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>
|
||||
)}
|
||||
|
||||
@@ -455,23 +491,15 @@ export function DeploymentsPage() {
|
||||
<SelectValue placeholder="Compose seçin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(composeOptions.length > 0
|
||||
? composeOptions
|
||||
: ["docker-compose.yml", "docker-compose.dev.yml"]
|
||||
).map((file) => (
|
||||
<SelectItem key={file} value={file}>
|
||||
{file}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(selectedCandidate?.composeFiles || ["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">
|
||||
|
||||
@@ -150,24 +150,7 @@ export function HomePage() {
|
||||
.slice(0, 10);
|
||||
}, [mergedRuns, deployRuns]);
|
||||
|
||||
const combinedTotals = useMemo(() => {
|
||||
const jobSuccess = metrics?.dailyStats.reduce((acc, d) => acc + (d.success || 0), 0) ?? 0;
|
||||
const jobTotal = metrics?.dailyStats.reduce((acc, d) => acc + (d.total || 0), 0) ?? 0;
|
||||
const deploySuccess =
|
||||
deploymentMetrics?.dailyStats.reduce((acc, d) => acc + (d.success || 0), 0) ?? 0;
|
||||
const deployTotal =
|
||||
deploymentMetrics?.dailyStats.reduce((acc, d) => acc + (d.total || 0), 0) ?? 0;
|
||||
const totalRuns = jobTotal + deployTotal;
|
||||
const successRate = totalRuns
|
||||
? Math.round(((jobSuccess + deploySuccess) / totalRuns) * 100)
|
||||
: 0;
|
||||
return { totalRuns, successRate };
|
||||
}, [metrics, deploymentMetrics]);
|
||||
|
||||
const lastRunDuration = useMemo(() => {
|
||||
const latest = activityItems[0];
|
||||
return formatDuration(latest?.durationMs);
|
||||
}, [activityItems]);
|
||||
const lastRunDuration = useMemo(() => formatDuration(mergedRuns[0]?.durationMs), [mergedRuns]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
@@ -180,7 +163,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" />
|
||||
{combinedTotals.totalRuns} toplam koşu
|
||||
{metrics?.totals.totalRuns ?? 0} toplam koşu
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-48 min-w-0">
|
||||
@@ -227,13 +210,13 @@ export function HomePage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Başarı Oranı</span>
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
{combinedTotals.successRate}%
|
||||
{metrics?.totals.successRate ?? 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Toplam Çalıştırma</span>
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
{combinedTotals.totalRuns}
|
||||
{metrics?.totals.totalRuns ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
Reference in New Issue
Block a user