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

@@ -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>
)}
</>
);
}