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

@@ -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>