feat(deployments): docker tabanlı proje yönetim ve otomatik deploy sistemi ekle

Docker Compose projeleri için tam kapsamlı yönetim paneli ve otomatik deployment altyapısı eklendi.

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

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

View File

@@ -5,6 +5,9 @@ import { DashboardLayout } from "./components/DashboardLayout";
import { HomePage } from "./pages/HomePage";
import { JobsPage } from "./pages/JobsPage";
import { JobDetailPage } from "./pages/JobDetailPage";
import { DeploymentsPage } from "./pages/DeploymentsPage";
import { DeploymentDetailPage } from "./pages/DeploymentDetailPage";
import { SettingsPage } from "./pages/SettingsPage";
function App() {
return (
@@ -15,6 +18,9 @@ function App() {
<Route path="/home" element={<HomePage />} />
<Route path="/jobs" element={<JobsPage />} />
<Route path="/jobs/:id" element={<JobDetailPage />} />
<Route path="/deployments" element={<DeploymentsPage />} />
<Route path="/deployments/:id" element={<DeploymentDetailPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/home" replace />} />
</Route>
</Route>

View File

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

View File

@@ -0,0 +1,22 @@
import { apiClient } from "./client";
export interface SettingsResponse {
webhookToken: string;
webhookSecret: string;
updatedAt: string;
}
export async function fetchSettings(): Promise<SettingsResponse> {
const { data } = await apiClient.get("/settings");
return data as SettingsResponse;
}
export async function rotateWebhookToken(): Promise<SettingsResponse> {
const { data } = await apiClient.post("/settings/token/rotate");
return data as SettingsResponse;
}
export async function rotateWebhookSecret(): Promise<SettingsResponse> {
const { data } = await apiClient.post("/settings/secret/rotate");
return data as SettingsResponse;
}

View File

@@ -1,7 +1,14 @@
import React, { useMemo, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHouse, faBriefcase, faArrowRightFromBracket, faUser, faFlaskVial } from "@fortawesome/free-solid-svg-icons";
import {
faHouse,
faArrowRightFromBracket,
faUser,
faFlaskVial,
faRocket,
faGear
} from "@fortawesome/free-solid-svg-icons";
import { Button } from "./ui/button";
import { ThemeToggle } from "./ThemeToggle";
import { useAuth } from "../providers/auth-provider";
@@ -15,7 +22,9 @@ export function DashboardLayout() {
const navigation = useMemo(
() => [
{ label: "Home", to: "/home", icon: faHouse },
{ label: "Jobs", to: "/jobs", icon: faFlaskVial }
{ label: "Jobs", to: "/jobs", icon: faFlaskVial },
{ label: "Deployments", to: "/deployments", icon: faRocket },
{ label: "Settings", to: "/settings", icon: faGear }
],
[]
);

View File

@@ -0,0 +1,221 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft, faCloudArrowUp, faCopy, faHistory } from "@fortawesome/free-solid-svg-icons";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { JobStatusBadge } from "../components/JobStatusBadge";
import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments";
export function DeploymentDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [project, setProject] = useState<DeploymentProject | null>(null);
const [runs, setRuns] = useState<DeploymentRun[]>([]);
const [loading, setLoading] = useState(true);
const [triggering, setTriggering] = useState(false);
useEffect(() => {
if (!id) return;
fetchDeployment(id)
.then((data) => {
setProject(data.project);
setRuns(data.runs);
})
.catch(() => toast.error("Deployment bulunamadı"))
.finally(() => setLoading(false));
}, [id]);
const webhookUrl = useMemo(() => {
if (!project) return "";
return `${window.location.origin}/api/deployments/webhook/${project.webhookToken}`;
}, [project]);
const latestRun = runs[0];
const decorateLogLine = (line: string) => {
const lower = line.toLowerCase();
if (lower.includes("error") || lower.includes("fail") || lower.includes("hata")) {
return `${line}`;
}
if (lower.includes("success") || lower.includes("başarılı") || lower.includes("completed")) {
return `${line}`;
}
if (lower.includes("docker")) {
return `🐳 ${line}`;
}
if (lower.includes("git")) {
return `🔧 ${line}`;
}
if (lower.includes("clone") || lower.includes("pull") || lower.includes("fetch")) {
return `📦 ${line}`;
}
return `${line}`;
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(webhookUrl);
toast.success("Webhook URL kopyalandı");
} catch {
toast.error("Webhook URL kopyalanamadı");
}
};
const handleRun = async () => {
if (!id) return;
setTriggering(true);
try {
await runDeployment(id);
toast.success("Deploy tetiklendi");
} catch {
toast.error("Deploy tetiklenemedi");
} finally {
setTriggering(false);
}
};
if (loading) {
return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
Deployment yükleniyor...
</div>
);
}
if (!project) {
return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
Deployment bulunamadı.
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate("/deployments")}>
<FontAwesomeIcon icon={faArrowLeft} />
</Button>
<div>
<h2 className="text-xl font-semibold text-foreground">{project.name}</h2>
<div className="text-sm text-muted-foreground">{project.rootPath}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => navigate("/deployments", { state: { editDeploymentId: project._id } })}
>
Düzenle
</Button>
<Button onClick={handleRun} disabled={triggering} className="gap-2">
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
{triggering ? "Deploying..." : "Deploy"}
</Button>
</div>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Genel Bilgiler</CardTitle>
<JobStatusBadge status={project.lastStatus} />
</CardHeader>
<CardContent className="grid gap-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center gap-3">
<span className="font-medium text-foreground">Repo:</span>
<span className="text-foreground/80">{project.repoUrl}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className="font-medium text-foreground">Branch:</span>
<span className="text-foreground/80">{project.branch}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className="font-medium text-foreground">Compose:</span>
<span className="text-foreground/80">{project.composeFile}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className="font-medium text-foreground">Env:</span>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
{project.env.toUpperCase()}
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className="font-medium text-foreground">Last Deploy:</span>
<span className="text-foreground/80">
{project.lastDeployAt ? new Date(project.lastDeployAt).toLocaleString() : "-"}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Webhook URL</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-3 rounded-md bg-muted px-3 py-2 text-sm">
<code className="break-all text-foreground/80">{webhookUrl}</code>
<Button variant="ghost" size="icon" onClick={handleCopy} title="Kopyala">
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faHistory} className="h-4 w-4" />
Deploy Geçmişi
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{runs.length === 0 && (
<div className="text-sm text-muted-foreground">Henüz deploy çalıştırılmadı.</div>
)}
{runs.map((run) => (
<div
key={run._id}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<div className="flex items-center gap-3">
<JobStatusBadge status={run.status} />
<span className="text-muted-foreground">
{new Date(run.startedAt).toLocaleString()}
</span>
{run.message && (
<span className="truncate text-foreground/80">· {run.message}</span>
)}
</div>
<div className="text-muted-foreground">
{run.durationMs ? `${Math.round(run.durationMs / 1000)}s` : "-"}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Son Deploy Logları</CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-72 overflow-auto rounded-md border border-border bg-black px-3 py-2 font-mono text-xs text-green-100">
{latestRun?.logs?.length ? (
latestRun.logs.map((line, idx) => (
<div key={idx} className="whitespace-pre-wrap">
{decorateLogLine(line)}
</div>
))
) : (
<div className="text-muted-foreground">Henüz log yok.</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

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

View File

@@ -13,10 +13,11 @@ import {
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { useLiveData } from "../providers/live-provider";
import { fetchJobMetrics, JobMetrics } from "../api/jobs";
import { fetchDeploymentMetrics, DeploymentMetrics, DeploymentRunWithProject } from "../api/deployments";
import { JobStatusBadge } from "../components/JobStatusBadge";
import { RepoIcon } from "../components/RepoIcon";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClockRotateLeft, faListCheck } from "@fortawesome/free-solid-svg-icons";
import { faClockRotateLeft, faListCheck, faFlaskVial, faRocket } from "@fortawesome/free-solid-svg-icons";
function formatDuration(ms?: number) {
if (!ms || Number.isNaN(ms)) return "-";
@@ -29,28 +30,79 @@ function formatDuration(ms?: number) {
return `${hours}sa ${minutes % 60}dk`;
}
function toYmd(date: Date) {
return date.toISOString().slice(0, 10);
}
export function HomePage() {
const [metrics, setMetrics] = useState<JobMetrics | null>(null);
const [deploymentMetrics, setDeploymentMetrics] = useState<DeploymentMetrics | null>(null);
const [deployRuns, setDeployRuns] = useState<DeploymentRunWithProject[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { jobStreams } = useLiveData();
const navigate = useNavigate();
useEffect(() => {
fetchJobMetrics()
.then(setMetrics)
.catch(() => setError("Metrikler alınamadı"))
Promise.allSettled([fetchJobMetrics(), fetchDeploymentMetrics()])
.then(([jobResult, deployResult]) => {
if (jobResult.status === "fulfilled") {
setMetrics(jobResult.value);
} else {
setMetrics({
dailyStats: [],
recentRuns: [],
totals: { successRate: 0, totalRuns: 0 }
});
setError("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>

View File

@@ -0,0 +1,165 @@
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy, faEye, faEyeSlash, faRotate } from "@fortawesome/free-solid-svg-icons";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { fetchSettings, rotateWebhookSecret, rotateWebhookToken, SettingsResponse } from "../api/settings";
export function SettingsPage() {
const [settings, setSettings] = useState<SettingsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [rotatingToken, setRotatingToken] = useState(false);
const [rotatingSecret, setRotatingSecret] = useState(false);
const [showToken, setShowToken] = useState(false);
const [showSecret, setShowSecret] = useState(false);
useEffect(() => {
fetchSettings()
.then((data) => setSettings(data))
.catch(() => toast.error("Settings yüklenemedi"))
.finally(() => setLoading(false));
}, []);
const handleCopy = async (value: string, label: string) => {
try {
await navigator.clipboard.writeText(value);
toast.success(`${label} kopyalandı`);
} catch {
toast.error(`${label} kopyalanamadı`);
}
};
const handleRotateToken = async () => {
const ok = window.confirm(
"API Token yenilenecek. Gitea webhook ayarları güncellenmezse mevcut deployment'lar tetiklenmez. Devam etmek istiyor musun?"
);
if (!ok) return;
setRotatingToken(true);
try {
const data = await rotateWebhookToken();
setSettings((prev) => (prev ? { ...prev, webhookToken: data.webhookToken } : data));
toast.success("API token yenilendi");
} catch {
toast.error("API token yenilenemedi");
} finally {
setRotatingToken(false);
}
};
const handleRotateSecret = async () => {
const ok = window.confirm(
"Webhook Secret yenilenecek. Gitea webhook ayarları güncellenmezse imza doğrulaması başarısız olur. Devam etmek istiyor musun?"
);
if (!ok) return;
setRotatingSecret(true);
try {
const data = await rotateWebhookSecret();
setSettings((prev) => (prev ? { ...prev, webhookSecret: data.webhookSecret } : data));
toast.success("Webhook secret yenilendi");
} catch {
toast.error("Webhook secret yenilenemedi");
} finally {
setRotatingSecret(false);
}
};
if (loading) {
return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
Settings yükleniyor...
</div>
);
}
if (!settings) {
return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
Settings bulunamadı.
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-foreground">Settings</h2>
<p className="text-sm text-muted-foreground">
Gitea webhook çağrıları için API token ve secret bilgileri.
</p>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>API Token</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRotateToken} disabled={rotatingToken}>
<FontAwesomeIcon icon={faRotate} className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-3 rounded-md bg-muted px-3 py-2 text-sm">
<code className="break-all text-foreground/80">
{showToken ? settings.webhookToken : "•".repeat(settings.webhookToken.length)}
</code>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowToken((prev) => !prev)}
title={showToken ? "Gizle" : "Göster"}
>
<FontAwesomeIcon icon={showToken ? faEyeSlash : faEye} className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(settings.webhookToken, "API token")}
title="Kopyala"
>
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Webhook Secret</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRotateSecret} disabled={rotatingSecret}>
<FontAwesomeIcon icon={faRotate} className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-3 rounded-md bg-muted px-3 py-2 text-sm">
<code className="break-all text-foreground/80">
{showSecret ? settings.webhookSecret : "•".repeat(settings.webhookSecret.length)}
</code>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowSecret((prev) => !prev)}
title={showSecret ? "Gizle" : "Göster"}
>
<FontAwesomeIcon icon={showSecret ? faEyeSlash : faEye} className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(settings.webhookSecret, "Webhook secret")}
title="Kopyala"
>
<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}