feat(jobs): işler için env konfigürasyonu ekle

İş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)
This commit is contained in:
2026-02-04 21:11:51 +00:00
parent 719ae4044e
commit a7091b084d
7 changed files with 513 additions and 142 deletions

View File

@@ -3,7 +3,7 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
COPY package*.json . 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 tsconfig.json .
COPY src ./src COPY src ./src

View File

@@ -8,6 +8,8 @@ export interface JobDocument extends Document {
testCommand: string; testCommand: string;
checkValue: number; checkValue: number;
checkUnit: TimeUnit; checkUnit: TimeUnit;
envContent?: string;
envExampleName?: string;
status: "idle" | "running" | "success" | "failed"; status: "idle" | "running" | "success" | "failed";
lastRunAt?: Date; lastRunAt?: Date;
lastDurationMs?: number; lastDurationMs?: number;
@@ -23,6 +25,8 @@ const JobSchema = new Schema<JobDocument>(
testCommand: { type: String, required: true, trim: true }, testCommand: { type: String, required: true, trim: true },
checkValue: { type: Number, required: true, min: 1 }, checkValue: { type: Number, required: true, min: 1 },
checkUnit: { type: String, required: true, enum: ["dakika", "saat", "gün"] }, 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" }, status: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" },
lastRunAt: { type: Date }, lastRunAt: { type: Date },
lastDurationMs: { type: Number }, lastDurationMs: { type: Number },

View File

@@ -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) => { router.get("/:id", async (req, res) => {
const { id } = req.params; const { id } = req.params;
const job = await Job.findById(id).lean(); const job = await Job.findById(id).lean();
@@ -89,12 +102,20 @@ router.get("/:id", async (req, res) => {
}); });
router.post("/", 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) { if (!name || !repoUrl || !testCommand || !checkValue || !checkUnit) {
return res.status(400).json({ message: "Tüm alanlar gerekli" }); return res.status(400).json({ message: "Tüm alanlar gerekli" });
} }
try { 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); await jobService.persistMetadata(job);
jobService.scheduleJob(job); jobService.scheduleJob(job);
// Yeni job oluşturulduğunda ilk test otomatik tetiklensin // Yeni job oluşturulduğunda ilk test otomatik tetiklensin
@@ -107,11 +128,11 @@ router.post("/", async (req, res) => {
router.put("/:id", async (req, res) => { router.put("/:id", async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { name, repoUrl, testCommand, checkValue, checkUnit } = req.body; const { name, repoUrl, testCommand, checkValue, checkUnit, envContent, envExampleName } = req.body;
try { try {
const job = await Job.findByIdAndUpdate( const job = await Job.findByIdAndUpdate(
id, id,
{ name, repoUrl, testCommand, checkValue, checkUnit }, { name, repoUrl, testCommand, checkValue, checkUnit, envContent, envExampleName },
{ new: true, runValidators: true } { new: true, runValidators: true }
); );
if (!job) return res.status(404).json({ message: "Job bulunamadı" }); if (!job) return res.status(404).json({ message: "Job bulunamadı" });

View File

@@ -25,6 +25,8 @@ type JobMetadata = {
testCommand: string; testCommand: string;
checkValue: number; checkValue: number;
checkUnit: TimeUnit; checkUnit: TimeUnit;
envContent?: string;
envExampleName?: string;
}; };
type StoredJobRun = { 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) { async function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) {
const repoDir = path.join(repoBaseDir, job._id.toString()); const repoDir = path.join(repoBaseDir, job._id.toString());
await ensureDir(repoDir); await ensureDir(repoDir);
@@ -173,7 +196,7 @@ async function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) {
if (!exists) { if (!exists) {
const entries = await fs.promises.readdir(repoDir); const entries = await fs.promises.readdir(repoDir);
const allowed = new Set<string>([jobMetadataFileName, jobRunsDirName]); const allowed = new Set<string>([jobMetadataFileName, jobRunsDirName, ".env", ".env.local"]);
const blocking = entries.filter((name) => !allowed.has(name)); const blocking = entries.filter((name) => !allowed.has(name));
if (blocking.length > 0) { if (blocking.length > 0) {
throw new Error("Repo klasoru git olmayan dosyalar iceriyor"); 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"); 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; let runsBackupPath: string | null = null;
const runsDir = path.join(repoDir, jobRunsDirName); const runsDir = path.join(repoDir, jobRunsDirName);
if (fs.existsSync(runsDir)) { if (fs.existsSync(runsDir)) {
@@ -205,6 +234,9 @@ async function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) {
if (metadataBackup) { if (metadataBackup) {
await fs.promises.writeFile(metadataPath, metadataBackup, "utf8"); await fs.promises.writeFile(metadataPath, metadataBackup, "utf8");
} }
if (envBackup) {
await fs.promises.writeFile(envPath, envBackup, "utf8");
}
if (runsBackupPath) { if (runsBackupPath) {
await fs.promises.rename(runsBackupPath, runsDir); await fs.promises.rename(runsBackupPath, runsDir);
} }
@@ -285,6 +317,10 @@ class JobService {
try { try {
const repoDir = await cloneOrPull(job, (line) => pushLog(line)); const repoDir = await cloneOrPull(job, (line) => pushLog(line));
await ensureDependencies(repoDir, (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}`); pushLog(`Test komutu çalıştırılıyor: ${job.testCommand}`);
await runCommand(job.testCommand, repoDir, (line) => pushLog(line), 180_000); await runCommand(job.testCommand, repoDir, (line) => pushLog(line), 180_000);
pushLog("Test tamamlandı: Başarılı"); pushLog("Test tamamlandı: Başarılı");
@@ -336,6 +372,10 @@ class JobService {
} }
} }
async listRemoteEnvExamples(repoUrl: string) {
return listRemoteEnvExamples(repoUrl);
}
scheduleJob(job: JobDocument) { scheduleJob(job: JobDocument) {
const intervalMs = job.checkValue * unitToMs(job.checkUnit); const intervalMs = job.checkValue * unitToMs(job.checkUnit);
if (!intervalMs || Number.isNaN(intervalMs)) return; if (!intervalMs || Number.isNaN(intervalMs)) return;
@@ -364,7 +404,9 @@ class JobService {
repoUrl: job.repoUrl, repoUrl: job.repoUrl,
testCommand: job.testCommand, testCommand: job.testCommand,
checkValue: job.checkValue, 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 }); const existing = await Job.findOne({ repoUrl: metadata.repoUrl });
if (existing) continue; 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({ const created = await Job.create({
name: metadata.name, name: metadata.name,
repoUrl: metadata.repoUrl, repoUrl: metadata.repoUrl,
testCommand: metadata.testCommand, testCommand: metadata.testCommand,
checkValue: metadata.checkValue, checkValue: metadata.checkValue,
checkUnit: metadata.checkUnit checkUnit: metadata.checkUnit,
envContent,
envExampleName: metadata.envExampleName
}); });
await this.persistMetadata(created); await this.persistMetadata(created);

View File

@@ -9,6 +9,8 @@ export interface Job {
testCommand: string; testCommand: string;
checkValue: number; checkValue: number;
checkUnit: TimeUnit; checkUnit: TimeUnit;
envContent?: string;
envExampleName?: string;
status?: "idle" | "running" | "success" | "failed"; status?: "idle" | "running" | "success" | "failed";
lastRunAt?: string; lastRunAt?: string;
lastDurationMs?: number; lastDurationMs?: number;
@@ -46,6 +48,8 @@ export interface JobInput {
testCommand: string; testCommand: string;
checkValue: number; checkValue: number;
checkUnit: TimeUnit; checkUnit: TimeUnit;
envContent?: string;
envExampleName?: string;
} }
export async function fetchJobs(): Promise<Job[]> { export async function fetchJobs(): Promise<Job[]> {
@@ -76,6 +80,13 @@ export async function runJob(id: string): Promise<void> {
await apiClient.post(`/jobs/${id}/run`); await apiClient.post(`/jobs/${id}/run`);
} }
export async function fetchJobEnvExamples(
repoUrl: string
): Promise<Array<{ name: string; content: string }>> {
const { data } = await apiClient.get("/jobs/env-examples", { params: { repoUrl } });
return (data as { examples: Array<{ name: string; content: string }> }).examples;
}
export interface JobMetrics { export interface JobMetrics {
dailyStats: Array<{ dailyStats: Array<{
_id: string; _id: string;

View File

@@ -16,7 +16,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { RepoIcon } from "../components/RepoIcon"; 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 { useJobStream } from "../providers/live-provider";
import { useSocket } from "../providers/socket-provider"; import { useSocket } from "../providers/socket-provider";
import { JobStatusBadge } from "../components/JobStatusBadge"; import { JobStatusBadge } from "../components/JobStatusBadge";
@@ -24,6 +24,7 @@ import { toast } from "sonner";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label"; import { Label } from "../components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
type FormState = { type FormState = {
name: string; name: string;
@@ -45,6 +46,12 @@ export function JobDetailPage() {
const [countdown, setCountdown] = useState<string>("00:00:00"); const [countdown, setCountdown] = useState<string>("00:00:00");
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [envExamples, setEnvExamples] = useState<Array<{ name: string; content: string }>>([]);
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<FormState>({ const [form, setForm] = useState<FormState>({
name: "", name: "",
repoUrl: "", repoUrl: "",
@@ -57,6 +64,39 @@ export function JobDetailPage() {
const logContainerRef = useRef<HTMLDivElement | null>(null); const logContainerRef = useRef<HTMLDivElement | null>(null);
const prevStatusRef = useRef<string | undefined>(undefined); const prevStatusRef = useRef<string | undefined>(undefined);
const currentLogs = stream.logs.length > 0 ? stream.logs : lastRun?.logs || []; 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(() => { useEffect(() => {
if (stream.runCount !== undefined) { if (stream.runCount !== undefined) {
@@ -78,6 +118,9 @@ export function JobDetailPage() {
checkValue: String(data.job.checkValue), checkValue: String(data.job.checkValue),
checkUnit: data.job.checkUnit checkUnit: data.job.checkUnit
}); });
setEnvContent(data.job.envContent || "");
setEnvExampleName(data.job.envExampleName || "");
setEnvDirty(false);
}) })
.catch(() => setError("Test bulunamadı")) .catch(() => setError("Test bulunamadı"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -169,6 +212,10 @@ export function JobDetailPage() {
checkValue: String(job.checkValue), checkValue: String(job.checkValue),
checkUnit: job.checkUnit checkUnit: job.checkUnit
}); });
setEnvContent(job.envContent || "");
setEnvExampleName(job.envExampleName || "");
setActiveTab("general");
setEnvDirty(false);
setEditOpen(true); setEditOpen(true);
}; };
@@ -194,7 +241,9 @@ export function JobDetailPage() {
repoUrl: form.repoUrl, repoUrl: form.repoUrl,
testCommand: form.testCommand, testCommand: form.testCommand,
checkValue: Number(form.checkValue), 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) { if (!payload.name || !payload.repoUrl || !payload.testCommand || !payload.checkValue) {
@@ -226,7 +275,45 @@ export function JobDetailPage() {
if (logContainerRef.current) { if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; 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(() => { useEffect(() => {
if (effectiveStatus === "running") { if (effectiveStatus === "running") {
@@ -375,7 +462,14 @@ export function JobDetailPage() {
</Button> </Button>
</div> </div>
<div className="space-y-4 px-5 py-4"> <div className="px-5 py-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4 min-h-[360px]">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Test Name</Label> <Label htmlFor="name">Test Name</Label>
<Input <Input
@@ -434,6 +528,48 @@ export function JobDetailPage() {
</Select> </Select>
</div> </div>
</div> </div>
</TabsContent>
<TabsContent value="environment" className="space-y-4 min-h-[360px]">
<div className="space-y-2">
<Label>.env</Label>
<div className="flex items-center gap-2">
<Select
value={envExampleName}
onValueChange={(value) => {
setEnvExampleName(value);
setEnvDirty(false);
const selected = envExamples.find((example) => example.name === value);
if (selected) setEnvContent(selected.content);
}}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder={envLoading ? "Yükleniyor..." : "Dosya seçin"} />
</SelectTrigger>
<SelectContent>
{envExamples.map((example) => (
<SelectItem key={example.name} value={example.name}>
{example.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>İçerik</Label>
<textarea
className="min-h-[300px] w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground"
value={envContent}
onChange={(e) => {
setEnvContent(e.target.value);
setEnvDirty(true);
}}
placeholder={envLoading ? "Yükleniyor..." : "ENV içeriği"}
/>
</div>
</TabsContent>
</Tabs>
</div> </div>
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4"> <div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
<Button variant="ghost" onClick={() => setEditOpen(false)} disabled={saving}> <Button variant="ghost" onClick={() => setEditOpen(false)} disabled={saving}>
@@ -506,11 +642,11 @@ export function JobDetailPage() {
ref={logContainerRef} ref={logContainerRef}
className="h-64 overflow-auto rounded-md border border-border bg-black p-3 font-mono text-xs text-green-100" className="h-64 overflow-auto rounded-md border border-border bg-black p-3 font-mono text-xs text-green-100"
> >
{currentLogs.length === 0 && ( {displayLogs.length === 0 && (
<div className="text-muted-foreground">Henüz çıktı yok. Test çalıştırmaları bekleniyor.</div> <div className="text-muted-foreground">Henüz çıktı yok. Test çalıştırmaları bekleniyor.</div>
)} )}
{currentLogs.map((line, idx) => ( {displayLogs.map((line, idx) => (
<div key={idx} className="whitespace-pre-wrap"> <div key={idx} className="whitespace-pre">
{line} {line}
</div> </div>
))} ))}

View File

@@ -1,7 +1,15 @@
import { useEffect, useMemo, useState } from "react"; import { CSSProperties, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faListCheck, faPlay, faPlus, faClockRotateLeft, faRepeat } from "@fortawesome/free-solid-svg-icons"; import {
faListCheck,
faPlay,
faPlus,
faClockRotateLeft,
faRepeat,
faEye,
faEyeSlash
} from "@fortawesome/free-solid-svg-icons";
import { Card, CardContent } from "../components/ui/card"; import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
@@ -13,8 +21,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "../components/ui/select"; } from "../components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { useLiveData } from "../providers/live-provider"; import { useLiveData } from "../providers/live-provider";
import { createJob, fetchJobs, Job, JobInput, runJob, updateJob } from "../api/jobs"; import { createJob, fetchJobs, fetchJobEnvExamples, Job, JobInput, runJob, updateJob } from "../api/jobs";
import { RepoIcon } from "../components/RepoIcon"; import { RepoIcon } from "../components/RepoIcon";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { JobStatusBadge } from "../components/JobStatusBadge"; import { JobStatusBadge } from "../components/JobStatusBadge";
@@ -49,6 +58,13 @@ export function JobsPage() {
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const [pendingEditId, setPendingEditId] = useState<string | null>(null); const [pendingEditId, setPendingEditId] = useState<string | null>(null);
const [runningId, setRunningId] = useState<string | null>(null); const [runningId, setRunningId] = useState<string | null>(null);
const [envExamples, setEnvExamples] = useState<Array<{ name: string; content: string }>>([]);
const [envLoading, setEnvLoading] = useState(false);
const [envContent, setEnvContent] = useState("");
const [envExampleName, setEnvExampleName] = useState("");
const [activeTab, setActiveTab] = useState("general");
const [showEnv, setShowEnv] = useState(true);
const [envDirty, setEnvDirty] = useState(false);
const isEdit = useMemo(() => !!form._id, [form._id]); const isEdit = useMemo(() => !!form._id, [form._id]);
@@ -113,6 +129,12 @@ export function JobsPage() {
const handleOpenNew = () => { const handleOpenNew = () => {
setForm(defaultForm); setForm(defaultForm);
setEnvExamples([]);
setEnvContent("");
setEnvExampleName("");
setActiveTab("general");
setShowEnv(true);
setEnvDirty(false);
setModalOpen(true); setModalOpen(true);
}; };
@@ -124,7 +146,9 @@ export function JobsPage() {
repoUrl: form.repoUrl, repoUrl: form.repoUrl,
testCommand: form.testCommand, testCommand: form.testCommand,
checkValue: Number(form.checkValue), 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) { if (!payload.name || !payload.repoUrl || !payload.testCommand || !payload.checkValue) {
@@ -174,6 +198,10 @@ export function JobsPage() {
const handleEdit = (job: Job) => { const handleEdit = (job: Job) => {
const { _id, name, repoUrl, testCommand, checkValue, checkUnit } = job; const { _id, name, repoUrl, testCommand, checkValue, checkUnit } = job;
setForm({ _id, name, repoUrl, testCommand, checkValue: String(checkValue), checkUnit }); setForm({ _id, name, repoUrl, testCommand, checkValue: String(checkValue), checkUnit });
setEnvContent(job.envContent || "");
setEnvExampleName(job.envExampleName || "");
setActiveTab("general");
setEnvDirty(false);
setModalOpen(true); setModalOpen(true);
}; };
@@ -181,6 +209,45 @@ export function JobsPage() {
setModalOpen(false); setModalOpen(false);
}; };
useEffect(() => {
const repoUrl = form.repoUrl.trim();
if (!repoUrl) {
setEnvExamples([]);
setEnvExampleName("");
if (!isEdit) {
setEnvContent("");
setEnvDirty(false);
}
return;
}
const timer = setTimeout(async () => {
setEnvLoading(true);
try {
const examples = await fetchJobEnvExamples(repoUrl);
setEnvExamples(examples);
if (examples.length === 0) {
if (!isEdit) {
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, isEdit, envContent, envDirty]);
return ( return (
<> <>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
@@ -277,7 +344,10 @@ export function JobsPage() {
{modalOpen && ( {modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
<div className="w-full max-w-lg rounded-lg border border-border bg-card card-shadow"> <div
className="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"
style={{ height: 626 }}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4"> <div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="space-y-1"> <div className="space-y-1">
<div className="text-lg font-semibold text-foreground">{isEdit ? "Test Güncelle" : "Yeni Test"}</div> <div className="text-lg font-semibold text-foreground">{isEdit ? "Test Güncelle" : "Yeni Test"}</div>
@@ -289,7 +359,17 @@ export function JobsPage() {
</Button> </Button>
</div> </div>
<div className="space-y-4 px-5 py-4"> <div className="flex-1 overflow-hidden px-5 py-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
</TabsList>
<TabsContent value="general" className="h-[420px] space-y-4">
<div className="h-[1.25rem] text-xs text-muted-foreground">
Repo URL girildiğinde env example dosyaları listelenir.
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Test Name</Label> <Label htmlFor="name">Test Name</Label>
<Input <Input
@@ -348,6 +428,75 @@ export function JobsPage() {
</Select> </Select>
</div> </div>
</div> </div>
</TabsContent>
<TabsContent value="environment" className="h-[420px] space-y-4">
<div className="space-y-2">
<Label>.env</Label>
{envExamples.length > 0 ? (
<Select
value={envExampleName}
onValueChange={(value) => {
setEnvExampleName(value);
setEnvDirty(false);
const selected = envExamples.find((example) => example.name === value);
if (selected) setEnvContent(selected.content);
}}
>
<SelectTrigger>
<SelectValue placeholder={envLoading ? "Yükleniyor..." : "Dosya seçin"} />
</SelectTrigger>
<SelectContent>
{envExamples.map((example) => (
<SelectItem key={example.name} value={example.name}>
{example.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="h-[2.5rem] 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 className="h-[1.25rem] text-xs text-muted-foreground">
{envExamples.length > 0
? "Repo üzerindeki env example dosyaları listelendi."
: 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>İçerik</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
className="h-[180px] w-full resize-none 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"
value={envContent}
onChange={(e) => {
setEnvContent(e.target.value);
setEnvDirty(true);
}}
placeholder={envLoading ? "Yükleniyor..." : "ENV içerikleri burada listelenir."}
style={showEnv ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties)}
/>
<div className="min-h-[1.25rem] text-xs text-muted-foreground">
Kaydedince içerik proje dizinine <span className="font-mono">.env</span> olarak yazılır.
</div>
</div>
</TabsContent>
</Tabs>
</div> </div>
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4"> <div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
<Button variant="ghost" onClick={handleClose} disabled={saving}> <Button variant="ghost" onClick={handleClose} disabled={saving}>