feat(deployments): düzenleme modalı ve deploy mesajı desteği ekle

Deployment detay sayfasında düzenleme modalı eklendi. Repo URL, branch,
compose dosyası ve environment değişkenleri inline düzenlenebilir hale
getirildi. Deploy tetikleme işlemi için özel mesaj parametresi desteği
eklendi. Düzenleme sonrası otomatik deploy tetikleme özelliği aktif edildi.
This commit is contained in:
2026-01-19 17:08:50 +03:00
parent 0092c28571
commit 2ff3fb6ee6
4 changed files with 434 additions and 10 deletions

View File

@@ -215,8 +215,10 @@ router.post("/:id/run", async (req, res) => {
const { id } = req.params; const { id } = req.params;
const project = await DeploymentProject.findById(id); const project = await DeploymentProject.findById(id);
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" }); if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
const rawMessage = typeof req.body?.message === "string" ? req.body.message.trim() : "";
const message = rawMessage || "Elle deploy tetikleme";
deploymentService deploymentService
.runDeployment(id, { message: "Elle deploy tetikleme" }) .runDeployment(id, { message })
.catch(() => undefined); .catch(() => undefined);
return res.json({ queued: true }); return res.json({ queued: true });
}); });

View File

@@ -92,8 +92,8 @@ export async function deleteDeployment(id: string): Promise<void> {
await apiClient.delete(`/deployments/${id}`); await apiClient.delete(`/deployments/${id}`);
} }
export async function runDeployment(id: string): Promise<void> { export async function runDeployment(id: string, message?: string): Promise<void> {
await apiClient.post(`/deployments/${id}/run`); await apiClient.post(`/deployments/${id}/run`, message ? { message } : {});
} }
export async function fetchDeployment(id: string): Promise<DeploymentDetailResponse> { export async function fetchDeployment(id: string): Promise<DeploymentDetailResponse> {

View File

@@ -1,15 +1,47 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft, faCloudArrowUp, faCopy, faHistory } from "@fortawesome/free-solid-svg-icons"; import {
faArrowLeft,
faCloudArrowUp,
faCopy,
faEye,
faEyeSlash,
faHistory
} from "@fortawesome/free-solid-svg-icons";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { JobStatusBadge } from "../components/JobStatusBadge"; import { JobStatusBadge } from "../components/JobStatusBadge";
import { DeploymentProject, DeploymentRun, fetchDeployment, runDeployment } from "../api/deployments"; 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 {
DeploymentInput,
DeploymentProject,
DeploymentRun,
fetchDeployment,
fetchDeploymentBranches,
fetchDeploymentComposeFiles,
fetchDeploymentEnvExamples,
runDeployment,
updateDeployment
} from "../api/deployments";
import { useDeploymentStream } from "../providers/live-provider"; import { useDeploymentStream } from "../providers/live-provider";
import { useSocket } from "../providers/socket-provider"; import { useSocket } from "../providers/socket-provider";
type FormState = {
_id?: string;
name: string;
repoUrl: string;
branch: string;
composeFile: DeploymentInput["composeFile"];
port: string;
};
type EnvExample = { name: string; content: string };
export function DeploymentDetailPage() { export function DeploymentDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -17,8 +49,28 @@ export function DeploymentDetailPage() {
const [runs, setRuns] = useState<DeploymentRun[]>([]); const [runs, setRuns] = useState<DeploymentRun[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [triggering, setTriggering] = useState(false); const [triggering, setTriggering] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>({
name: "",
repoUrl: "",
branch: "main",
composeFile: "docker-compose.yml",
port: ""
});
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 stream = useDeploymentStream(id || ""); const stream = useDeploymentStream(id || "");
const socket = useSocket(); const socket = useSocket();
const isEdit = useMemo(() => !!form._id, [form._id]);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -117,6 +169,142 @@ export function DeploymentDetailPage() {
} }
}; };
useEffect(() => {
const repoUrl = form.repoUrl.trim();
if (!repoUrl) {
setBranchOptions([]);
setComposeOptions([]);
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 repoUrl = form.repoUrl.trim();
const branch = form.branch.trim();
if (!repoUrl || !branch) {
setEnvExamples([]);
setEnvExampleName("");
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) {
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, envContent]);
const handleEdit = () => {
if (!project) return;
const { _id, name, repoUrl, branch, composeFile, port } = project;
setForm({
_id,
name,
repoUrl,
branch,
composeFile,
port: port ? String(port) : ""
});
setEnvContent(project.envContent || "");
setEnvExampleName(project.envExampleName || "");
setShowEnv(false);
setActiveTab("details");
setModalOpen(true);
};
const handleClose = () => {
setModalOpen(false);
};
const handleSave = async () => {
if (!form._id) return;
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;
}
const updated = await updateDeployment(form._id, payload);
setProject(updated);
try {
await runDeployment(updated._id, "Update deploy");
} catch {
toast.error("Deploy tetiklenemedi");
}
toast.success("Deployment güncellendi");
setModalOpen(false);
} catch {
toast.error("İşlem sırasında hata oluştu");
} finally {
setSaving(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground"> <div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
@@ -134,6 +322,7 @@ export function DeploymentDetailPage() {
} }
return ( return (
<>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -148,7 +337,7 @@ export function DeploymentDetailPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => navigate("/deployments", { state: { editDeploymentId: project._id } })} onClick={handleEdit}
> >
Düzenle Düzenle
</Button> </Button>
@@ -258,5 +447,230 @@ export function DeploymentDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </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: 620 }}
>
<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">Deployment Güncelle</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>
)}
</>
); );
} }

View File

@@ -264,6 +264,11 @@ export function DeploymentsPage() {
envExampleName: payload.envExampleName envExampleName: payload.envExampleName
}); });
setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d))); 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"); toast.success("Deployment güncellendi");
} else { } else {
const created = await createDeployment(payload); const created = await createDeployment(payload);
@@ -435,7 +440,10 @@ export function DeploymentsPage() {
{modalOpen && ( {modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
<div className="flex h-[620px] w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"> <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="flex items-center justify-between border-b border-border px-5 py-4">
<div className="space-y-1"> <div className="space-y-1">
<div className="text-lg font-semibold text-foreground"> <div className="text-lg font-semibold text-foreground">