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:
532
frontend/src/pages/DeploymentsPage.tsx
Normal file
532
frontend/src/pages/DeploymentsPage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user