From fd020bd9d8a08560f2d292ed1cd0c096f5e098a1 Mon Sep 17 00:00:00 2001 From: wisecolt Date: Mon, 19 Jan 2026 15:46:22 +0300 Subject: [PATCH] =?UTF-8?q?feat(deployments):=20environment=20variable=20d?= =?UTF-8?q?este=C4=9Fi=20ekle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployment projelerine environment variable konfigürasyonu eklendi. Backend tarafında DeploymentProject modeline envContent ve envExampleName alanları eklendi. Repo içindeki .env.example dosyalarını listelemek için yeni bir endpoint eklendi. Deployment sürecinde belirlenen env içeriği .proje dizinine .env dosyası olarak yazılıyor. Frontend tarafında deployment formuna "Genel" ve "Environment" sekmeleri eklendi. Remote repodan .env.example dosyaları çekilebiliyor ve içerik düzenlenebiliyor. Env içeriği için göster/gizle toggle'ı eklendi. --- backend/src/models/deploymentProject.ts | 4 + backend/src/routes/deployments.ts | 29 +- backend/src/services/deploymentService.ts | 42 ++- frontend/package.json | 1 + frontend/src/api/deployments.ts | 14 + frontend/src/components/ui/tabs.tsx | 49 +++ frontend/src/pages/DeploymentsPage.tsx | 344 +++++++++++++++------- 7 files changed, 372 insertions(+), 111 deletions(-) create mode 100644 frontend/src/components/ui/tabs.tsx diff --git a/backend/src/models/deploymentProject.ts b/backend/src/models/deploymentProject.ts index 59b5eef..dd1bd33 100644 --- a/backend/src/models/deploymentProject.ts +++ b/backend/src/models/deploymentProject.ts @@ -13,6 +13,8 @@ export interface DeploymentProjectDocument extends Document { webhookToken: string; env: DeploymentEnv; port?: number; + envContent?: string; + envExampleName?: string; lastDeployAt?: Date; lastStatus: DeploymentStatus; lastMessage?: string; @@ -34,6 +36,8 @@ const DeploymentProjectSchema = new Schema( webhookToken: { type: String, required: true, unique: true, index: true }, env: { type: String, required: true, enum: ["dev", "prod"] }, port: { type: Number }, + envContent: { type: String }, + envExampleName: { type: String }, lastDeployAt: { type: Date }, lastStatus: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" }, lastMessage: { type: String } diff --git a/backend/src/routes/deployments.ts b/backend/src/routes/deployments.ts index dbcf724..4e76288 100644 --- a/backend/src/routes/deployments.ts +++ b/backend/src/routes/deployments.ts @@ -1,5 +1,4 @@ import { Router } from "express"; -import fs from "fs"; import path from "path"; import { authMiddleware } from "../middleware/authMiddleware.js"; import { deploymentService } from "../services/deploymentService.js"; @@ -71,6 +70,22 @@ router.get("/compose-files", async (req, res) => { }); }); +router.get("/env-examples", async (req, res) => { + authMiddleware(req, res, async () => { + const repoUrl = req.query.repoUrl as string | undefined; + const branch = req.query.branch as string | undefined; + if (!repoUrl || !branch) { + return res.status(400).json({ message: "repoUrl ve branch gerekli" }); + } + try { + const examples = await deploymentService.listRemoteEnvExamples(repoUrl, branch); + return res.json({ examples }); + } catch (err) { + return res.status(400).json({ message: "Env example alınamadı", error: (err as Error).message }); + } + }); +}); + router.get("/metrics/summary", async (req, res) => { authMiddleware(req, res, async () => { const since = new Date(); @@ -129,7 +144,7 @@ router.get("/:id", async (req, res) => { router.post("/", async (req, res) => { authMiddleware(req, res, async () => { - const { name, repoUrl, branch, composeFile, port } = req.body; + const { name, repoUrl, branch, composeFile, port, envContent, envExampleName } = req.body; if (!name || !repoUrl || !branch || !composeFile) { return res.status(400).json({ message: "Tüm alanlar gerekli" }); } @@ -139,7 +154,9 @@ router.post("/", async (req, res) => { repoUrl, branch, composeFile, - port + port, + envContent, + envExampleName }); deploymentService .runDeployment(created._id.toString(), { message: "First deployment" }) @@ -154,7 +171,7 @@ router.post("/", async (req, res) => { router.put("/:id", async (req, res) => { authMiddleware(req, res, async () => { const { id } = req.params; - const { name, repoUrl, branch, composeFile, port } = req.body; + const { name, repoUrl, branch, composeFile, port, envContent, envExampleName } = req.body; if (!name || !repoUrl || !branch || !composeFile) { return res.status(400).json({ message: "Tüm alanlar gerekli" }); } @@ -164,7 +181,9 @@ router.put("/:id", async (req, res) => { repoUrl, branch, composeFile, - port + port, + envContent, + envExampleName }); if (!updated) return res.status(404).json({ message: "Deployment bulunamadı" }); return res.json(updated); diff --git a/backend/src/services/deploymentService.ts b/backend/src/services/deploymentService.ts index e9b2591..494d8a2 100644 --- a/backend/src/services/deploymentService.ts +++ b/backend/src/services/deploymentService.ts @@ -218,6 +218,32 @@ class DeploymentService { } } + async listRemoteEnvExamples(repoUrl: string, branch: string) { + await fs.promises.mkdir(deploymentsRoot, { recursive: true }); + const tmpBase = await fs.promises.mkdtemp(path.join(deploymentsRoot, ".tmp-")); + try { + await runCommand( + `git clone --depth 1 --single-branch --branch ${branch} ${repoUrl} ${tmpBase}`, + process.cwd(), + () => undefined + ); + const entries = await fs.promises.readdir(tmpBase, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((name) => name.toLowerCase().endsWith(".env.example")); + const items = await Promise.all( + files.map(async (name) => ({ + name, + content: await fs.promises.readFile(path.join(tmpBase, name), "utf8") + })) + ); + return items; + } finally { + await fs.promises.rm(tmpBase, { recursive: true, force: true }); + } + } + async ensureSettings() { const existing = await Settings.findOne(); if (existing) return existing; @@ -248,6 +274,8 @@ class DeploymentService { branch: string; composeFile: ComposeFile; port?: number; + envContent?: string; + envExampleName?: string; }) { const repoUrl = normalizeRepoUrl(input.repoUrl); const existingRepo = await DeploymentProject.findOne({ repoUrl }); @@ -281,7 +309,9 @@ class DeploymentService { composeFile: input.composeFile, webhookToken, env, - port: input.port + port: input.port, + envContent: input.envContent, + envExampleName: input.envExampleName }); } @@ -293,6 +323,8 @@ class DeploymentService { branch: string; composeFile: ComposeFile; port?: number; + envContent?: string; + envExampleName?: string; } ) { const project = await DeploymentProject.findById(id); @@ -317,7 +349,9 @@ class DeploymentService { branch: input.branch, composeFile: input.composeFile, env, - port: input.port + port: input.port, + envContent: input.envContent, + envExampleName: input.envExampleName }, { new: true, runValidators: true } ); @@ -362,6 +396,10 @@ class DeploymentService { try { await ensureRepo(project, (line) => pushLog(line)); + if (project.envContent) { + await fs.promises.writeFile(path.join(project.rootPath, ".env"), project.envContent, "utf8"); + pushLog(".env güncellendi"); + } pushLog("Deploy komutları çalıştırılıyor..."); await runCompose(project, (line) => pushLog(line)); const duration = Date.now() - startedAt; diff --git a/frontend/package.json b/frontend/package.json index ec89314..16d1558 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@fortawesome/react-fontawesome": "^3.1.0", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.1.3", "axios": "^1.5.1", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", diff --git a/frontend/src/api/deployments.ts b/frontend/src/api/deployments.ts index 0573197..32486e1 100644 --- a/frontend/src/api/deployments.ts +++ b/frontend/src/api/deployments.ts @@ -14,6 +14,8 @@ export interface DeploymentProject { webhookToken: string; env: DeploymentEnv; port?: number; + envContent?: string; + envExampleName?: string; lastDeployAt?: string; lastStatus: DeploymentStatus; lastMessage?: string; @@ -60,6 +62,8 @@ export interface DeploymentInput { branch: string; composeFile: ComposeFile; port?: number; + envContent?: string; + envExampleName?: string; } export async function fetchDeployments(): Promise { @@ -111,3 +115,13 @@ export async function fetchDeploymentComposeFiles( }); return (data as { files: ComposeFile[] }).files; } + +export async function fetchDeploymentEnvExamples( + repoUrl: string, + branch: string +): Promise> { + const { data } = await apiClient.get("/deployments/env-examples", { + params: { repoUrl, branch } + }); + return (data as { examples: Array<{ name: string; content: string }> }).examples; +} diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..3607692 --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { cn } from "../../lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/frontend/src/pages/DeploymentsPage.tsx b/frontend/src/pages/DeploymentsPage.tsx index 1f6413c..2584dae 100644 --- a/frontend/src/pages/DeploymentsPage.tsx +++ b/frontend/src/pages/DeploymentsPage.tsx @@ -1,9 +1,11 @@ -import { useEffect, useMemo, useState } from "react"; +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, faPlus, faRotate, faRocket @@ -13,6 +15,7 @@ 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, @@ -20,6 +23,7 @@ import { DeploymentProject, fetchDeploymentComposeFiles, fetchDeploymentBranches, + fetchDeploymentEnvExamples, fetchDeployments, runDeployment, updateDeployment @@ -36,6 +40,8 @@ type FormState = { port: string; }; +type EnvExample = { name: string; content: string }; + const defaultForm: FormState = { name: "", repoUrl: "", @@ -59,6 +65,12 @@ export function DeploymentsPage() { const [branchLoading, setBranchLoading] = useState(false); const [composeOptions, setComposeOptions] = useState([]); const [composeLoading, setComposeLoading] = useState(false); + const [envExamples, setEnvExamples] = useState([]); + 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>({}); const isEdit = useMemo(() => !!form._id, [form._id]); @@ -115,6 +127,11 @@ export function DeploymentsPage() { const repoUrl = form.repoUrl.trim(); const branch = form.branch.trim(); if (!repoUrl || !branch) { + setEnvExamples([]); + setEnvExampleName(""); + if (!isEdit) { + setEnvContent(""); + } setComposeOptions([]); return; } @@ -135,6 +152,38 @@ export function DeploymentsPage() { 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) { @@ -156,6 +205,11 @@ export function DeploymentsPage() { setForm(defaultForm); setBranchOptions([]); setComposeOptions([]); + setEnvExamples([]); + setEnvContent(""); + setEnvExampleName(""); + setShowEnv(false); + setActiveTab("details"); setModalOpen(true); }; @@ -169,6 +223,10 @@ export function DeploymentsPage() { composeFile, port: port ? String(port) : "" }); + setEnvContent(deployment.envContent || ""); + setEnvExampleName(deployment.envExampleName || ""); + setShowEnv(false); + setActiveTab("details"); setModalOpen(true); }; @@ -184,7 +242,9 @@ export function DeploymentsPage() { repoUrl: form.repoUrl, branch: form.branch, composeFile: form.composeFile, - port: form.port ? Number(form.port) : undefined + port: form.port ? Number(form.port) : undefined, + envContent: envContent.trim() ? envContent : undefined, + envExampleName: envExampleName || undefined }; if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) { @@ -199,7 +259,9 @@ export function DeploymentsPage() { repoUrl: payload.repoUrl, branch: payload.branch, composeFile: payload.composeFile, - port: payload.port + port: payload.port, + envContent: payload.envContent, + envExampleName: payload.envExampleName }); setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d))); toast.success("Deployment güncellendi"); @@ -388,116 +450,190 @@ export function DeploymentsPage() { -
- {!isEdit && ( -
- Repo URL girildiğinde branch ve compose dosyaları listelenir. -
- )} +
+ + + Genel + Environment + -
- - setForm((prev) => ({ ...prev, repoUrl: e.target.value }))} - placeholder="https://gitea.example.com/org/repo" - required - /> -
+ + {!isEdit && ( +
+ Repo URL girildiğinde branch ve compose dosyaları listelenir. +
+ )} -
-
- - setForm((prev) => ({ ...prev, name: e.target.value }))} - placeholder="wisecolt-app" - required - /> -
-
- - {branchOptions.length > 0 ? ( - - ) : ( +
+ setForm((prev) => ({ ...prev, branch: e.target.value }))} - placeholder="main" + id="repo" + value={form.repoUrl} + onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))} + placeholder="https://gitea.example.com/org/repo" required /> - )} -
- {branchLoading - ? "Branch listesi alınıyor..." - : branchOptions.length > 0 - ? "Repo üzerindeki branch'lar listelendi." - : "Repo URL girildiğinde branch listesi otomatik gelir."}
-
-
-
-
- - -
- {composeLoading - ? "Compose dosyaları alınıyor..." - : composeOptions.length > 0 - ? "Repo üzerindeki compose dosyaları listelendi." - : "Repo URL ve branch sonrası compose dosyaları listelenir."} +
+
+ + setForm((prev) => ({ ...prev, name: e.target.value }))} + placeholder="wisecolt-app" + required + /> +
+
+ + {branchOptions.length > 0 ? ( + + ) : ( + setForm((prev) => ({ ...prev, branch: e.target.value }))} + placeholder="main" + required + /> + )} +
+ {branchLoading + ? "Branch listesi alınıyor..." + : branchOptions.length > 0 + ? "Repo üzerindeki branch'lar listelendi." + : "Repo URL girildiğinde branch listesi otomatik gelir."} +
+
-
-
- - setForm((prev) => ({ ...prev, port: e.target.value }))} - placeholder="3000" - /> -
-
+
+
+ + +
+ {composeLoading + ? "Compose dosyaları alınıyor..." + : composeOptions.length > 0 + ? "Repo üzerindeki compose dosyaları listelendi." + : "Repo URL ve branch sonrası compose dosyaları listelenir."} +
+
+ +
+ + setForm((prev) => ({ ...prev, port: e.target.value }))} + placeholder="3000" + /> +
+
+ + + +
+ + {envExamples.length > 0 ? ( + + ) : ( +
+ {envLoading + ? "Env example dosyaları alınıyor..." + : "Repo içinde .env.example bulunamadı."} +
+ )} +
+ +
+
+ + +
+