From a7091b084d6457a4e3cf602686820c46be9b9af9 Mon Sep 17 00:00:00 2001 From: wisecolt Date: Wed, 4 Feb 2026 21:11:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(jobs):=20i=C5=9Fler=20i=C3=A7in=20env=20ko?= =?UTF-8?q?nfig=C3=BCrasyonu=20ekle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit İşlere .env dosyası konfigürasyonu özelliği eklendi. Kullanıcılar artık depodan .env.example dosyalarını listeleyebilir, seçebilir ve içeriklerini düzenleyebilir. Backend: - Job modeline envContent ve envExampleName alanları eklendi - /jobs/env-examples endpoint'i eklendi - cloneOrPull ile .env dosyaları korunur - İş çalıştırma sırasında .env otomatik oluşturulur - Dockerfile'a bash, curl, jq eklendi Frontend: - İş formlarına Environment sekmesi eklendi - .env.example dosyaları seçilebilir - Env içeriği düzenlenebilir ve gizlenebilir - Log görüntüleme iyileştirildi (progress bar desteği) --- backend/Dockerfile | 2 +- backend/src/models/job.ts | 4 + backend/src/routes/jobs.ts | 29 ++- backend/src/services/jobService.ts | 66 ++++++- frontend/src/api/jobs.ts | 11 ++ frontend/src/pages/JobDetailPage.tsx | 266 ++++++++++++++++++------- frontend/src/pages/JobsPage.tsx | 277 ++++++++++++++++++++------- 7 files changed, 513 insertions(+), 142 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 1c488f2..0278875 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,7 +3,7 @@ FROM node:20-alpine WORKDIR /app COPY package*.json . -RUN apk add --no-cache git openssh-client docker-cli docker-cli-compose && npm install +RUN apk add --no-cache bash curl jq git openssh-client docker-cli docker-cli-compose && npm install COPY tsconfig.json . COPY src ./src diff --git a/backend/src/models/job.ts b/backend/src/models/job.ts index 499460b..e890041 100644 --- a/backend/src/models/job.ts +++ b/backend/src/models/job.ts @@ -8,6 +8,8 @@ export interface JobDocument extends Document { testCommand: string; checkValue: number; checkUnit: TimeUnit; + envContent?: string; + envExampleName?: string; status: "idle" | "running" | "success" | "failed"; lastRunAt?: Date; lastDurationMs?: number; @@ -23,6 +25,8 @@ const JobSchema = new Schema( testCommand: { type: String, required: true, trim: true }, checkValue: { type: Number, required: true, min: 1 }, checkUnit: { type: String, required: true, enum: ["dakika", "saat", "gün"] }, + envContent: { type: String }, + envExampleName: { type: String }, status: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" }, lastRunAt: { type: Date }, lastDurationMs: { type: Number }, diff --git a/backend/src/routes/jobs.ts b/backend/src/routes/jobs.ts index 0ee452c..6d13c6c 100644 --- a/backend/src/routes/jobs.ts +++ b/backend/src/routes/jobs.ts @@ -79,6 +79,19 @@ router.get("/metrics/summary", async (_req, res) => { }); }); +router.get("/env-examples", async (req, res) => { + const repoUrl = req.query.repoUrl as string | undefined; + if (!repoUrl) { + return res.status(400).json({ message: "repoUrl gerekli" }); + } + try { + const examples = await jobService.listRemoteEnvExamples(repoUrl); + return res.json({ examples }); + } catch (err) { + return res.status(400).json({ message: "Env example alınamadı", error: (err as Error).message }); + } +}); + router.get("/:id", async (req, res) => { const { id } = req.params; const job = await Job.findById(id).lean(); @@ -89,12 +102,20 @@ router.get("/:id", async (req, res) => { }); router.post("/", async (req, res) => { - const { name, repoUrl, testCommand, checkValue, checkUnit } = req.body; + const { name, repoUrl, testCommand, checkValue, checkUnit, envContent, envExampleName } = req.body; if (!name || !repoUrl || !testCommand || !checkValue || !checkUnit) { return res.status(400).json({ message: "Tüm alanlar gerekli" }); } try { - const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit }); + const job = await Job.create({ + name, + repoUrl, + testCommand, + checkValue, + checkUnit, + envContent, + envExampleName + }); await jobService.persistMetadata(job); jobService.scheduleJob(job); // Yeni job oluşturulduğunda ilk test otomatik tetiklensin @@ -107,11 +128,11 @@ router.post("/", async (req, res) => { router.put("/:id", async (req, res) => { const { id } = req.params; - const { name, repoUrl, testCommand, checkValue, checkUnit } = req.body; + const { name, repoUrl, testCommand, checkValue, checkUnit, envContent, envExampleName } = req.body; try { const job = await Job.findByIdAndUpdate( id, - { name, repoUrl, testCommand, checkValue, checkUnit }, + { name, repoUrl, testCommand, checkValue, checkUnit, envContent, envExampleName }, { new: true, runValidators: true } ); if (!job) return res.status(404).json({ message: "Job bulunamadı" }); diff --git a/backend/src/services/jobService.ts b/backend/src/services/jobService.ts index 3524e55..5bee94d 100644 --- a/backend/src/services/jobService.ts +++ b/backend/src/services/jobService.ts @@ -25,6 +25,8 @@ type JobMetadata = { testCommand: string; checkValue: number; checkUnit: TimeUnit; + envContent?: string; + envExampleName?: string; }; type StoredJobRun = { @@ -165,6 +167,27 @@ function runCommand( }); } +async function listRemoteEnvExamples(repoUrl: string) { + await fs.promises.mkdir(repoBaseDir, { recursive: true }); + const tmpBase = await fs.promises.mkdtemp(path.join(repoBaseDir, ".tmp-")); + try { + await runCommand(`git clone --depth 1 ${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 function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) { const repoDir = path.join(repoBaseDir, job._id.toString()); await ensureDir(repoDir); @@ -173,7 +196,7 @@ async function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) { if (!exists) { const entries = await fs.promises.readdir(repoDir); - const allowed = new Set([jobMetadataFileName, jobRunsDirName]); + const allowed = new Set([jobMetadataFileName, jobRunsDirName, ".env", ".env.local"]); const blocking = entries.filter((name) => !allowed.has(name)); if (blocking.length > 0) { throw new Error("Repo klasoru git olmayan dosyalar iceriyor"); @@ -185,6 +208,12 @@ async function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) { metadataBackup = await fs.promises.readFile(metadataPath, "utf8"); } + let envBackup: string | null = null; + const envPath = path.join(repoDir, ".env"); + if (fs.existsSync(envPath)) { + envBackup = await fs.promises.readFile(envPath, "utf8"); + } + let runsBackupPath: string | null = null; const runsDir = path.join(repoDir, jobRunsDirName); if (fs.existsSync(runsDir)) { @@ -205,6 +234,9 @@ async function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) { if (metadataBackup) { await fs.promises.writeFile(metadataPath, metadataBackup, "utf8"); } + if (envBackup) { + await fs.promises.writeFile(envPath, envBackup, "utf8"); + } if (runsBackupPath) { await fs.promises.rename(runsBackupPath, runsDir); } @@ -282,11 +314,15 @@ class JobService { await Job.findByIdAndUpdate(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." }); await this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument); - try { - const repoDir = await cloneOrPull(job, (line) => pushLog(line)); - await ensureDependencies(repoDir, (line) => pushLog(line)); - pushLog(`Test komutu çalıştırılıyor: ${job.testCommand}`); - await runCommand(job.testCommand, repoDir, (line) => pushLog(line), 180_000); + try { + const repoDir = await cloneOrPull(job, (line) => pushLog(line)); + await ensureDependencies(repoDir, (line) => pushLog(line)); + if (job.envContent) { + await fs.promises.writeFile(path.join(repoDir, ".env"), job.envContent, "utf8"); + pushLog(".env güncellendi"); + } + pushLog(`Test komutu çalıştırılıyor: ${job.testCommand}`); + await runCommand(job.testCommand, repoDir, (line) => pushLog(line), 180_000); pushLog("Test tamamlandı: Başarılı"); const duration = Date.now() - startedAt; await Job.findByIdAndUpdate(jobId, { @@ -336,6 +372,10 @@ class JobService { } } + async listRemoteEnvExamples(repoUrl: string) { + return listRemoteEnvExamples(repoUrl); + } + scheduleJob(job: JobDocument) { const intervalMs = job.checkValue * unitToMs(job.checkUnit); if (!intervalMs || Number.isNaN(intervalMs)) return; @@ -364,7 +404,9 @@ class JobService { repoUrl: job.repoUrl, testCommand: job.testCommand, checkValue: job.checkValue, - checkUnit: job.checkUnit + checkUnit: job.checkUnit, + envContent: job.envContent, + envExampleName: job.envExampleName }); } @@ -392,12 +434,20 @@ class JobService { const existing = await Job.findOne({ repoUrl: metadata.repoUrl }); if (existing) continue; + let envContent = metadata.envContent; + const envPath = path.join(jobDir, ".env"); + if (!envContent && fs.existsSync(envPath)) { + envContent = await fs.promises.readFile(envPath, "utf8"); + } + const created = await Job.create({ name: metadata.name, repoUrl: metadata.repoUrl, testCommand: metadata.testCommand, checkValue: metadata.checkValue, - checkUnit: metadata.checkUnit + checkUnit: metadata.checkUnit, + envContent, + envExampleName: metadata.envExampleName }); await this.persistMetadata(created); diff --git a/frontend/src/api/jobs.ts b/frontend/src/api/jobs.ts index ee9847c..7cd48c6 100644 --- a/frontend/src/api/jobs.ts +++ b/frontend/src/api/jobs.ts @@ -9,6 +9,8 @@ export interface Job { testCommand: string; checkValue: number; checkUnit: TimeUnit; + envContent?: string; + envExampleName?: string; status?: "idle" | "running" | "success" | "failed"; lastRunAt?: string; lastDurationMs?: number; @@ -46,6 +48,8 @@ export interface JobInput { testCommand: string; checkValue: number; checkUnit: TimeUnit; + envContent?: string; + envExampleName?: string; } export async function fetchJobs(): Promise { @@ -76,6 +80,13 @@ export async function runJob(id: string): Promise { await apiClient.post(`/jobs/${id}/run`); } +export async function fetchJobEnvExamples( + repoUrl: string +): Promise> { + const { data } = await apiClient.get("/jobs/env-examples", { params: { repoUrl } }); + return (data as { examples: Array<{ name: string; content: string }> }).examples; +} + export interface JobMetrics { dailyStats: Array<{ _id: string; diff --git a/frontend/src/pages/JobDetailPage.tsx b/frontend/src/pages/JobDetailPage.tsx index 95ed8ca..dce6535 100644 --- a/frontend/src/pages/JobDetailPage.tsx +++ b/frontend/src/pages/JobDetailPage.tsx @@ -16,7 +16,7 @@ import { import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; import { Button } from "../components/ui/button"; import { RepoIcon } from "../components/RepoIcon"; -import { deleteJob, fetchJob, Job, JobInput, JobRun, runJob, updateJob } from "../api/jobs"; +import { deleteJob, fetchJob, fetchJobEnvExamples, Job, JobInput, JobRun, runJob, updateJob } from "../api/jobs"; import { useJobStream } from "../providers/live-provider"; import { useSocket } from "../providers/socket-provider"; import { JobStatusBadge } from "../components/JobStatusBadge"; @@ -24,6 +24,7 @@ import { toast } from "sonner"; 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"; type FormState = { name: string; @@ -45,6 +46,12 @@ export function JobDetailPage() { const [countdown, setCountdown] = useState("00:00:00"); const [editOpen, setEditOpen] = useState(false); const [saving, setSaving] = useState(false); + const [envExamples, setEnvExamples] = useState>([]); + const [envLoading, setEnvLoading] = useState(false); + const [envContent, setEnvContent] = useState(""); + const [envExampleName, setEnvExampleName] = useState(""); + const [activeTab, setActiveTab] = useState("general"); + const [envDirty, setEnvDirty] = useState(false); const [form, setForm] = useState({ name: "", repoUrl: "", @@ -57,6 +64,39 @@ export function JobDetailPage() { const logContainerRef = useRef(null); const prevStatusRef = useRef(undefined); const currentLogs = stream.logs.length > 0 ? stream.logs : lastRun?.logs || []; + const displayLogs = useMemo(() => { + const lines: string[] = []; + currentLogs.forEach((entry) => { + if (!entry.includes("\r")) { + lines.push(entry); + return; + } + const parts = entry.split("\r"); + parts.forEach((part, index) => { + if (index === 0 && part === "") { + return; + } + if (lines.length === 0) { + lines.push(part); + return; + } + lines[lines.length - 1] = part; + }); + }); + + const merged: string[] = []; + const percentOnly = /^\s*\d{1,3}%\s*$/; + const barLine = /[░▒▓█▉▊▋▌▍▎▏]+/; + lines.forEach((line) => { + if (merged.length > 0 && percentOnly.test(line) && barLine.test(merged[merged.length - 1])) { + merged[merged.length - 1] = `${merged[merged.length - 1].replace(/\s+$/, "")} ${line.trim()}`; + return; + } + merged.push(line); + }); + + return merged; + }, [currentLogs]); useEffect(() => { if (stream.runCount !== undefined) { @@ -78,6 +118,9 @@ export function JobDetailPage() { checkValue: String(data.job.checkValue), checkUnit: data.job.checkUnit }); + setEnvContent(data.job.envContent || ""); + setEnvExampleName(data.job.envExampleName || ""); + setEnvDirty(false); }) .catch(() => setError("Test bulunamadı")) .finally(() => setLoading(false)); @@ -169,6 +212,10 @@ export function JobDetailPage() { checkValue: String(job.checkValue), checkUnit: job.checkUnit }); + setEnvContent(job.envContent || ""); + setEnvExampleName(job.envExampleName || ""); + setActiveTab("general"); + setEnvDirty(false); setEditOpen(true); }; @@ -194,7 +241,9 @@ export function JobDetailPage() { repoUrl: form.repoUrl, testCommand: form.testCommand, checkValue: Number(form.checkValue), - checkUnit: form.checkUnit + checkUnit: form.checkUnit, + envContent: envContent.trim() ? envContent : undefined, + envExampleName: envExampleName || undefined }; if (!payload.name || !payload.repoUrl || !payload.testCommand || !payload.checkValue) { @@ -226,7 +275,45 @@ export function JobDetailPage() { if (logContainerRef.current) { logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; } - }, [currentLogs]); + }, [displayLogs]); + + useEffect(() => { + const repoUrl = form.repoUrl.trim(); + if (!repoUrl) { + setEnvExamples([]); + setEnvExampleName(""); + if (!editOpen) { + setEnvContent(""); + } + return; + } + const timer = setTimeout(async () => { + setEnvLoading(true); + try { + const examples = await fetchJobEnvExamples(repoUrl); + setEnvExamples(examples); + if (examples.length === 0) { + if (!editOpen) { + setEnvExampleName(""); + setEnvContent(""); + setEnvDirty(false); + } + return; + } + if (envDirty) return; + const selected = examples.find((example) => example.name === envExampleName) || examples[0]; + if (!envContent) { + setEnvExampleName(selected.name); + setEnvContent(selected.content); + } + } catch { + setEnvExamples([]); + } finally { + setEnvLoading(false); + } + }, 400); + return () => clearTimeout(timer); + }, [form.repoUrl, envExampleName, envContent, editOpen, envDirty]); useEffect(() => { if (effectiveStatus === "running") { @@ -375,65 +462,114 @@ export function JobDetailPage() { ✕ -
-
- - setForm((prev) => ({ ...prev, name: e.target.value }))} - placeholder="Nightly E2E" - required - /> -
-
- - setForm((prev) => ({ ...prev, repoUrl: e.target.value }))} - placeholder="https://github.com/org/repo" - required - /> -
-
- - setForm((prev) => ({ ...prev, testCommand: e.target.value }))} - placeholder="npm test" - required - /> -
-
- -
- setForm((prev) => ({ ...prev, checkValue: e.target.value }))} - /> - -
-
+
+ + + General + Environment + + + +
+ + setForm((prev) => ({ ...prev, name: e.target.value }))} + placeholder="Nightly E2E" + required + /> +
+
+ + setForm((prev) => ({ ...prev, repoUrl: e.target.value }))} + placeholder="https://github.com/org/repo" + required + /> +
+
+ + setForm((prev) => ({ ...prev, testCommand: e.target.value }))} + placeholder="npm test" + required + /> +
+
+ +
+ setForm((prev) => ({ ...prev, checkValue: e.target.value }))} + /> + +
+
+
+ + +
+ +
+ +
+
+
+ +