Files
Wisecolt-CI/frontend/src/pages/DeploymentsPage.tsx
wisecolt b04ac03739 feat(deployments): deployment restart özelliği ekle
Deployment projeleri için yeniden başlatma (restart) yeteneği eklendi.
Backend servisi, API endpoint'i ve kullanıcı arayüzü butonları güncellendi.
2026-02-03 08:53:03 +00:00

691 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { toast } from "sonner";
import { useLocation, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCloudArrowUp,
faEye,
faEyeSlash,
faPenToSquare,
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import {
createDeployment,
deleteDeployment,
DeploymentInput,
DeploymentProject,
fetchDeploymentComposeFiles,
fetchDeploymentBranches,
fetchDeploymentEnvExamples,
fetchDeployments,
restartDeployment,
runDeployment,
updateDeployment
} from "../api/deployments";
import { JobStatusBadge } from "../components/JobStatusBadge";
import { useLiveData } from "../providers/live-provider";
type FormState = {
_id?: string;
name: string;
repoUrl: string;
branch: string;
composeFile: DeploymentInput["composeFile"];
port: string;
};
type EnvExample = { name: string; content: string };
const defaultForm: FormState = {
name: "",
repoUrl: "",
branch: "main",
composeFile: "docker-compose.yml",
port: ""
};
export function DeploymentsPage() {
const navigate = useNavigate();
const location = useLocation();
const { deploymentStreams } = useLiveData();
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 [form, setForm] = useState<FormState>(defaultForm);
const [pendingEditId, setPendingEditId] = useState<string | null>(null);
const [branchOptions, setBranchOptions] = useState<string[]>([]);
const [branchLoading, setBranchLoading] = useState(false);
const [composeOptions, setComposeOptions] = useState<DeploymentInput["composeFile"][]>([]);
const [composeLoading, setComposeLoading] = useState(false);
const [envExamples, setEnvExamples] = useState<EnvExample[]>([]);
const [envLoading, setEnvLoading] = useState(false);
const [envContent, setEnvContent] = useState("");
const [envExampleName, setEnvExampleName] = useState("");
const [showEnv, setShowEnv] = useState(false);
const [activeTab, setActiveTab] = useState("details");
const [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({});
const isEdit = useMemo(() => !!form._id, [form._id]);
const loadDeployments = async () => {
setLoading(true);
try {
const data = await fetchDeployments();
setDeployments(data);
} catch {
toast.error("Deployment listesi alınamadı");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadDeployments();
}, []);
useEffect(() => {
const repoUrl = form.repoUrl.trim();
if (!repoUrl) {
setBranchOptions([]);
setComposeOptions([]);
return;
}
if (!form._id && !form.name) {
const normalized = repoUrl.replace(/\/+$/, "");
const lastPart = normalized.split("/").pop() || "";
const name = lastPart.replace(/\.git$/i, "");
if (name) {
setForm((prev) => ({ ...prev, name }));
}
}
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 repoUrl = form.repoUrl.trim();
const branch = form.branch.trim();
if (!repoUrl || !branch) {
setEnvExamples([]);
setEnvExampleName("");
if (!isEdit) {
setEnvContent("");
}
setComposeOptions([]);
return;
}
const timer = setTimeout(async () => {
setComposeLoading(true);
try {
const files = await fetchDeploymentComposeFiles(repoUrl, branch);
setComposeOptions(files);
if (files.length > 0 && !files.includes(form.composeFile)) {
setForm((prev) => ({ ...prev, composeFile: files[0] }));
}
} catch {
setComposeOptions([]);
} finally {
setComposeLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [form.repoUrl, form.branch, form.composeFile]);
useEffect(() => {
const repoUrl = form.repoUrl.trim();
const branch = form.branch.trim();
if (!repoUrl || !branch) {
return;
}
const timer = setTimeout(async () => {
setEnvLoading(true);
try {
const examples = await fetchDeploymentEnvExamples(repoUrl, branch);
setEnvExamples(examples);
if (examples.length === 0) {
if (!isEdit) {
setEnvExampleName("");
setEnvContent("");
}
return;
}
const selected = examples.find((example) => example.name === envExampleName) || examples[0];
if (!isEdit || !envContent) {
setEnvExampleName(selected.name);
setEnvContent(selected.content);
}
} catch {
setEnvExamples([]);
} finally {
setEnvLoading(false);
}
}, 400);
return () => clearTimeout(timer);
}, [form.repoUrl, form.branch, envExampleName, isEdit]);
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([]);
setComposeOptions([]);
setEnvExamples([]);
setEnvContent("");
setEnvExampleName("");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true);
};
const handleEdit = (deployment: DeploymentProject) => {
const { _id, name, repoUrl, branch, composeFile, port } = deployment;
setForm({
_id,
name,
repoUrl,
branch,
composeFile,
port: port ? String(port) : ""
});
setEnvContent(deployment.envContent || "");
setEnvExampleName(deployment.envExampleName || "");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true);
};
const handleClose = () => {
setModalOpen(false);
};
const handleSave = async () => {
setSaving(true);
try {
const payload: DeploymentInput = {
name: form.name,
repoUrl: form.repoUrl,
branch: form.branch,
composeFile: form.composeFile,
port: form.port ? Number(form.port) : undefined,
envContent: envContent.trim() ? envContent : undefined,
envExampleName: envExampleName || undefined
};
if (!payload.name || !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,
envContent: payload.envContent,
envExampleName: payload.envExampleName
});
setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d)));
try {
await runDeployment(updated._id, "update deploy");
} catch {
toast.error("Deploy tetiklenemedi");
}
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 handleRestart = async (id: string) => {
try {
await restartDeployment(id, "restart");
toast.success("Restart tetiklendi");
} catch {
toast.error("Restart 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={deploymentStreams[deployment._id]?.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();
handleRestart(deployment._id);
}}
title="Restart"
aria-label="Restart"
>
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleEdit(deployment);
}}
title="Düzenle"
>
<FontAwesomeIcon icon={faPenToSquare} 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="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"
style={{ height: 626 }}
>
<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="flex-1 overflow-hidden px-5 py-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="details">Genel</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
</TabsList>
<TabsContent value="details" className="h-[420px] space-y-4">
{!isEdit && (
<div className="h-[1.25rem] text-xs text-muted-foreground">
Repo URL girildiğinde branch ve compose dosyaları listelenir.
</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="h-[1.25rem] 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>
{(composeOptions.length > 0
? composeOptions
: ["docker-compose.yml", "docker-compose.dev.yml"]
).map((file) => (
<SelectItem key={file} value={file}>
{file}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="h-[1.25rem] text-xs text-muted-foreground">
{composeLoading
? "Compose dosyaları alınıyor..."
: composeOptions.length > 0
? "Repo üzerindeki compose dosyaları listelendi."
: "Repo URL ve branch sonrası compose dosyaları listelenir."}
</div>
</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>
</TabsContent>
<TabsContent value="environment" className="h-[420px] space-y-4">
<div className="space-y-2">
<Label>.env.example</Label>
{envExamples.length > 0 ? (
<Select
value={envExampleName}
onValueChange={(value) => {
const example = envExamples.find((item) => item.name === value);
setEnvExampleName(value);
if (example) {
setEnvContent(example.content);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Env example seçin" />
</SelectTrigger>
<SelectContent>
{envExamples.map((example) => (
<SelectItem key={example.name} value={example.name}>
{example.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="h-[2.5rem] rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
{envLoading
? "Env example dosyaları alınıyor..."
: "Repo içinde .env.example bulunamadı."}
</div>
)}
<div className="h-[1.25rem] text-xs text-muted-foreground">
{envExamples.length > 0
? "Repo üzerindeki env example dosyaları listelendi."
: envLoading
? "Env example dosyaları alınıyor..."
: "Repo içinde .env.example bulunamadı."}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="env-content">Environment</Label>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowEnv((prev) => !prev)}
>
<FontAwesomeIcon icon={showEnv ? faEyeSlash : faEye} className="h-4 w-4" />
</Button>
</div>
<textarea
id="env-content"
value={envContent}
onChange={(e) => setEnvContent(e.target.value)}
className="h-[180px] w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm font-mono text-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={
showEnv ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties)
}
placeholder="ENV içerikleri burada listelenir."
/>
<div className="min-h-[1.25rem] text-xs text-muted-foreground">
Kaydedince içerik deployment kök dizinine <span className="font-mono">.env</span> olarak yazılır.
</div>
</div>
</TabsContent>
</Tabs>
</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>
)}
</>
);
}