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:
@@ -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,116 +450,190 @@ export function DeploymentsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-5 py-4">
|
||||
{!isEdit && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Repo URL girildiğinde branch ve compose dosyaları listelenir.
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
{!isEdit && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Repo URL girildiğinde branch ve compose dosyaları listelenir.
|
||||
</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>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repo">Repo URL</Label>
|
||||
<Input
|
||||
id="branch"
|
||||
value={form.branch}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
)}
|
||||
<div className="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="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 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="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>
|
||||
|
||||
<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>
|
||||
<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="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="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">
|
||||
|
||||
Reference in New Issue
Block a user