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ı."} +
+ )} +
+ +
+
+ + +
+