feat(deployments): environment variable desteği ekle

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.
This commit is contained in:
2026-01-19 15:46:22 +03:00
parent e7a5690d98
commit fd020bd9d8
7 changed files with 372 additions and 111 deletions

View File

@@ -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<DeploymentProjectDocument>(
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 }

View File

@@ -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);

View File

@@ -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;

View File

@@ -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",

View File

@@ -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<DeploymentProject[]> {
@@ -111,3 +115,13 @@ export async function fetchDeploymentComposeFiles(
});
return (data as { files: ComposeFile[] }).files;
}
export async function fetchDeploymentEnvExamples(
repoUrl: string,
branch: string
): Promise<Array<{ name: string; content: string }>> {
const { data } = await apiClient.get("/deployments/env-examples", {
params: { repoUrl, branch }
});
return (data as { examples: Array<{ name: string; content: string }> }).examples;
}

View File

@@ -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<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-lg bg-muted/50 p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn("mt-4 ring-offset-background focus-visible:outline-none", className)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -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<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]);
@@ -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,7 +450,14 @@ export function DeploymentsPage() {
</Button>
</div>
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4">
<div className="max-h-[70vh] overflow-y-auto 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="space-y-4">
{!isEdit && (
<div className="text-xs text-muted-foreground">
Repo URL girildiğinde branch ve compose dosyaları listelenir.
@@ -460,7 +529,10 @@ export function DeploymentsPage() {
<Select
value={form.composeFile}
onValueChange={(value) =>
setForm((prev) => ({ ...prev, composeFile: value as DeploymentInput["composeFile"] }))
setForm((prev) => ({
...prev,
composeFile: value as DeploymentInput["composeFile"]
}))
}
>
<SelectTrigger>
@@ -498,6 +570,70 @@ export function DeploymentsPage() {
/>
</div>
</div>
</TabsContent>
<TabsContent value="environment" className="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="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>
<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="min-h-[180px] w-full resize-y 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="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">