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
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import {
|
||
Line,
|
||
LineChart,
|
||
CartesianGrid,
|
||
XAxis,
|
||
YAxis,
|
||
Tooltip,
|
||
ResponsiveContainer,
|
||
Legend
|
||
} from "recharts";
|
||
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, faFlaskVial, faRocket } from "@fortawesome/free-solid-svg-icons";
|
||
|
||
function formatDuration(ms?: number) {
|
||
if (!ms || Number.isNaN(ms)) return "-";
|
||
const seconds = Math.round(ms / 1000);
|
||
if (seconds < 60) return `${seconds}s`;
|
||
const minutes = Math.floor(seconds / 60);
|
||
const rem = seconds % 60;
|
||
if (minutes < 60) return `${minutes}dk ${rem}s`;
|
||
const hours = Math.floor(minutes / 60);
|
||
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(() => {
|
||
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) {
|
||
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 [];
|
||
return metrics.recentRuns.map((run, idx) => {
|
||
const live = jobStreams[run.job._id];
|
||
// Sadece en güncel run (liste başı) canlı güncellemeleri alır; geçmiş kayıtlar sabit kalır.
|
||
if (idx === 0 && live?.status) {
|
||
return {
|
||
...run,
|
||
status: live.status,
|
||
finishedAt: live.lastRunAt || run.finishedAt,
|
||
durationMs: live.lastDurationMs ?? run.durationMs
|
||
};
|
||
}
|
||
return run;
|
||
});
|
||
}, [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 (
|
||
<div className="grid gap-6">
|
||
<div className="grid gap-4 lg:grid-cols-3">
|
||
<Card className="border-border card-shadow lg:col-span-2">
|
||
<CardHeader className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Son 7 Gün Çalıştırma Trendleri</CardTitle>
|
||
<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-48 min-w-0">
|
||
{loading ? (
|
||
<div className="text-sm text-muted-foreground">Yükleniyor...</div>
|
||
) : chartData.length === 0 ? (
|
||
<div className="text-sm text-muted-foreground">Grafik verisi yok.</div>
|
||
) : (
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<LineChart data={chartData}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||
<XAxis dataKey="date" />
|
||
<YAxis allowDecimals={false} />
|
||
<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="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>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-border card-shadow">
|
||
<CardHeader>
|
||
<CardTitle>Hızlı Metrikler</CardTitle>
|
||
<CardDescription>Özet görünüm</CardDescription>
|
||
</CardHeader>
|
||
<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">
|
||
{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">
|
||
{metrics?.totals.totalRuns ?? 0}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span>Son Süre</span>
|
||
<span className="text-lg font-semibold text-foreground">{lastRunDuration}</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="border-border card-shadow">
|
||
<CardHeader className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Etkinlik Akışı</CardTitle>
|
||
<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" />
|
||
{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 && activityItems.length === 0 && (
|
||
<div className="text-sm text-muted-foreground">Henüz çalıştırma yok.</div>
|
||
)}
|
||
{!loading &&
|
||
activityItems.map((run) => (
|
||
<button
|
||
key={run.id}
|
||
type="button"
|
||
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.repoUrl} />
|
||
<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)}
|
||
{run.type === "deploy" && run.message ? ` · ${run.message}` : ""}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<JobStatusBadge status={run.status} />
|
||
</button>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|