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:
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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ı" });
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -282,11 +314,15 @@ class JobService {
|
|||||||
await Job.findByIdAndUpdate(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." });
|
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);
|
await this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument);
|
||||||
|
|
||||||
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));
|
||||||
pushLog(`Test komutu çalıştırılıyor: ${job.testCommand}`);
|
if (job.envContent) {
|
||||||
await runCommand(job.testCommand, repoDir, (line) => pushLog(line), 180_000);
|
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ı");
|
pushLog("Test tamamlandı: Başarılı");
|
||||||
const duration = Date.now() - startedAt;
|
const duration = Date.now() - startedAt;
|
||||||
await Job.findByIdAndUpdate(jobId, {
|
await Job.findByIdAndUpdate(jobId, {
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,65 +462,114 @@ export function JobDetailPage() {
|
|||||||
✕
|
✕
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<div className="space-y-2">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||||
<Label htmlFor="name">Test Name</Label>
|
<TabsList>
|
||||||
<Input
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
id="name"
|
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||||
value={form.name}
|
</TabsList>
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
|
||||||
placeholder="Nightly E2E"
|
<TabsContent value="general" className="space-y-4 min-h-[360px]">
|
||||||
required
|
<div className="space-y-2">
|
||||||
/>
|
<Label htmlFor="name">Test Name</Label>
|
||||||
</div>
|
<Input
|
||||||
<div className="space-y-2">
|
id="name"
|
||||||
<Label htmlFor="repo">Repo URL</Label>
|
value={form.name}
|
||||||
<Input
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
id="repo"
|
placeholder="Nightly E2E"
|
||||||
value={form.repoUrl}
|
required
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
|
/>
|
||||||
placeholder="https://github.com/org/repo"
|
</div>
|
||||||
required
|
<div className="space-y-2">
|
||||||
/>
|
<Label htmlFor="repo">Repo URL</Label>
|
||||||
</div>
|
<Input
|
||||||
<div className="space-y-2">
|
id="repo"
|
||||||
<Label htmlFor="test">Test Command</Label>
|
value={form.repoUrl}
|
||||||
<Input
|
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
|
||||||
id="test"
|
placeholder="https://github.com/org/repo"
|
||||||
value={form.testCommand}
|
required
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, testCommand: e.target.value }))}
|
/>
|
||||||
placeholder="npm test"
|
</div>
|
||||||
required
|
<div className="space-y-2">
|
||||||
/>
|
<Label htmlFor="test">Test Command</Label>
|
||||||
</div>
|
<Input
|
||||||
<div className="space-y-2">
|
id="test"
|
||||||
<Label>Check Time</Label>
|
value={form.testCommand}
|
||||||
<div className="flex gap-3">
|
onChange={(e) => setForm((prev) => ({ ...prev, testCommand: e.target.value }))}
|
||||||
<Input
|
placeholder="npm test"
|
||||||
type="number"
|
required
|
||||||
min={1}
|
/>
|
||||||
className="w-32"
|
</div>
|
||||||
value={form.checkValue}
|
<div className="space-y-2">
|
||||||
placeholder="15"
|
<Label>Check Time</Label>
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, checkValue: e.target.value }))}
|
<div className="flex gap-3">
|
||||||
/>
|
<Input
|
||||||
<Select
|
type="number"
|
||||||
value={form.checkUnit}
|
min={1}
|
||||||
onValueChange={(value) =>
|
className="w-32"
|
||||||
setForm((prev) => ({ ...prev, checkUnit: value as JobInput["checkUnit"] }))
|
value={form.checkValue}
|
||||||
}
|
placeholder="15"
|
||||||
>
|
onChange={(e) => setForm((prev) => ({ ...prev, checkValue: e.target.value }))}
|
||||||
<SelectTrigger className="flex-1">
|
/>
|
||||||
<SelectValue placeholder="Birim seçin" />
|
<Select
|
||||||
</SelectTrigger>
|
value={form.checkUnit}
|
||||||
<SelectContent>
|
onValueChange={(value) =>
|
||||||
<SelectItem value="dakika">dakika</SelectItem>
|
setForm((prev) => ({ ...prev, checkUnit: value as JobInput["checkUnit"] }))
|
||||||
<SelectItem value="saat">saat</SelectItem>
|
}
|
||||||
<SelectItem value="gün">gün</SelectItem>
|
>
|
||||||
</SelectContent>
|
<SelectTrigger className="flex-1">
|
||||||
</Select>
|
<SelectValue placeholder="Birim seçin" />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
|
<SelectItem value="dakika">dakika</SelectItem>
|
||||||
|
<SelectItem value="saat">saat</SelectItem>
|
||||||
|
<SelectItem value="gün">gün</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,65 +359,144 @@ 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">
|
||||||
<div className="space-y-2">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||||
<Label htmlFor="name">Test Name</Label>
|
<TabsList>
|
||||||
<Input
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
id="name"
|
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||||
value={form.name}
|
</TabsList>
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
|
||||||
placeholder="Nightly E2E"
|
<TabsContent value="general" className="h-[420px] space-y-4">
|
||||||
required
|
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||||||
/>
|
Repo URL girildiğinde env example dosyaları listelenir.
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="repo">Repo URL</Label>
|
<Label htmlFor="name">Test Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="repo"
|
id="name"
|
||||||
value={form.repoUrl}
|
value={form.name}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
placeholder="https://github.com/org/repo"
|
placeholder="Nightly E2E"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="test">Test Command</Label>
|
<Label htmlFor="repo">Repo URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="test"
|
id="repo"
|
||||||
value={form.testCommand}
|
value={form.repoUrl}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, testCommand: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
|
||||||
placeholder="npm test"
|
placeholder="https://github.com/org/repo"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Check Time</Label>
|
<Label htmlFor="test">Test Command</Label>
|
||||||
<div className="flex gap-3">
|
<Input
|
||||||
<Input
|
id="test"
|
||||||
type="number"
|
value={form.testCommand}
|
||||||
min={1}
|
onChange={(e) => setForm((prev) => ({ ...prev, testCommand: e.target.value }))}
|
||||||
className="w-32"
|
placeholder="npm test"
|
||||||
value={form.checkValue}
|
required
|
||||||
placeholder="15"
|
/>
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, checkValue: e.target.value }))}
|
</div>
|
||||||
/>
|
<div className="space-y-2">
|
||||||
<Select
|
<Label>Check Time</Label>
|
||||||
value={form.checkUnit}
|
<div className="flex gap-3">
|
||||||
onValueChange={(value) =>
|
<Input
|
||||||
setForm((prev) => ({ ...prev, checkUnit: value as JobInput["checkUnit"] }))
|
type="number"
|
||||||
}
|
min={1}
|
||||||
>
|
className="w-32"
|
||||||
<SelectTrigger className="flex-1">
|
value={form.checkValue}
|
||||||
<SelectValue placeholder="Birim seçin" />
|
placeholder="15"
|
||||||
</SelectTrigger>
|
onChange={(e) => setForm((prev) => ({ ...prev, checkValue: e.target.value }))}
|
||||||
<SelectContent>
|
/>
|
||||||
<SelectItem value="dakika">dakika</SelectItem>
|
<Select
|
||||||
<SelectItem value="saat">saat</SelectItem>
|
value={form.checkUnit}
|
||||||
<SelectItem value="gün">gün</SelectItem>
|
onValueChange={(value) =>
|
||||||
</SelectContent>
|
setForm((prev) => ({ ...prev, checkUnit: value as JobInput["checkUnit"] }))
|
||||||
</Select>
|
}
|
||||||
</div>
|
>
|
||||||
</div>
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Birim seçin" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="dakika">dakika</SelectItem>
|
||||||
|
<SelectItem value="saat">saat</SelectItem>
|
||||||
|
<SelectItem value="gün">gün</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user