From f6b73dacd27543fef57b7568f203e98c69aa2dbe Mon Sep 17 00:00:00 2001 From: sbilketay Date: Wed, 26 Nov 2025 23:14:41 +0300 Subject: [PATCH] =?UTF-8?q?Test=20mod=C3=BCl=C3=BC=20eklendi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 2 +- backend/src/index.ts | 29 ++++ backend/src/models/job.ts | 10 +- backend/src/routes/jobs.ts | 19 +++ backend/src/services/jobService.ts | 181 +++++++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/api/jobs.ts | 13 ++ frontend/src/components/RepoIcon.tsx | 21 +++ frontend/src/components/ui/toaster.tsx | 2 +- frontend/src/pages/JobDetailPage.tsx | 147 ++++++++++++++++++ frontend/src/pages/JobsPage.tsx | 42 +++--- frontend/src/providers/live-provider.tsx | 51 ++++++- 12 files changed, 490 insertions(+), 29 deletions(-) create mode 100644 backend/src/services/jobService.ts create mode 100644 frontend/src/components/RepoIcon.tsx create mode 100644 frontend/src/pages/JobDetailPage.tsx diff --git a/backend/Dockerfile b/backend/Dockerfile index 414976f..6e58f00 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,7 +3,7 @@ FROM node:20-alpine WORKDIR /app COPY package*.json . -RUN npm install +RUN apk add --no-cache git openssh-client && npm install COPY tsconfig.json . COPY src ./src diff --git a/backend/src/index.ts b/backend/src/index.ts index f43242d..bae4e45 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,8 @@ import authRoutes from "./routes/auth.js"; import jobsRoutes from "./routes/jobs.js"; import { config } from "./config/env.js"; import jwt from "jsonwebtoken"; +import { jobService } from "./services/jobService.js"; +import { Job } from "./models/job.js"; const app = express(); @@ -34,6 +36,8 @@ const io = new Server(server, { } }); +jobService.setSocket(io); + let counter = 0; let counterTimer: NodeJS.Timeout | null = null; @@ -90,12 +94,37 @@ io.on("connection", (socket) => { socket.on("counter:status", (ack?: (payload: { running: boolean; value: number }) => void) => { ack?.({ running: !!counterTimer, value: counter }); }); + + socket.on("job:subscribe", async ({ jobId }: { jobId: string }) => { + if (!jobId) return; + socket.join(`job:${jobId}`); + try { + const job = await Job.findById(jobId); + if (job) { + socket.emit("job:status", { + jobId, + status: job.status, + lastRunAt: job.lastRunAt, + lastDurationMs: job.lastDurationMs, + lastMessage: job.lastMessage + }); + } + } catch { + // sessizce geç + } + }); + + socket.on("job:unsubscribe", ({ jobId }: { jobId: string }) => { + if (!jobId) return; + socket.leave(`job:${jobId}`); + }); }); async function start() { try { await mongoose.connect(config.mongoUri); console.log("MongoDB'ye bağlanıldı"); + await jobService.bootstrap(); server.listen(config.port, () => { console.log(`Sunucu ${config.port} portunda çalışıyor`); diff --git a/backend/src/models/job.ts b/backend/src/models/job.ts index 6460186..499460b 100644 --- a/backend/src/models/job.ts +++ b/backend/src/models/job.ts @@ -8,6 +8,10 @@ export interface JobDocument extends Document { testCommand: string; checkValue: number; checkUnit: TimeUnit; + status: "idle" | "running" | "success" | "failed"; + lastRunAt?: Date; + lastDurationMs?: number; + lastMessage?: string; createdAt: Date; updatedAt: Date; } @@ -18,7 +22,11 @@ const JobSchema = new Schema( repoUrl: { type: String, required: true, trim: true }, testCommand: { type: String, required: true, trim: true }, 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"] }, + status: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" }, + lastRunAt: { type: Date }, + lastDurationMs: { type: Number }, + lastMessage: { type: String } }, { timestamps: true } ); diff --git a/backend/src/routes/jobs.ts b/backend/src/routes/jobs.ts index c1a0f93..9bbb730 100644 --- a/backend/src/routes/jobs.ts +++ b/backend/src/routes/jobs.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { authMiddleware } from "../middleware/authMiddleware.js"; import { Job } from "../models/job.js"; +import { jobService } from "../services/jobService.js"; const router = Router(); @@ -11,6 +12,13 @@ router.get("/", async (_req, res) => { res.json(jobs); }); +router.get("/:id", async (req, res) => { + const { id } = req.params; + const job = await Job.findById(id).lean(); + if (!job) return res.status(404).json({ message: "Job bulunamadı" }); + return res.json(job); +}); + router.post("/", async (req, res) => { const { name, repoUrl, testCommand, checkValue, checkUnit } = req.body; if (!name || !repoUrl || !testCommand || !checkValue || !checkUnit) { @@ -18,6 +26,7 @@ router.post("/", async (req, res) => { } try { const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit }); + jobService.scheduleJob(job); return res.status(201).json(job); } catch (err) { return res.status(400).json({ message: "Job oluşturulamadı", error: (err as Error).message }); @@ -34,6 +43,7 @@ router.put("/:id", async (req, res) => { { new: true, runValidators: true } ); if (!job) return res.status(404).json({ message: "Job bulunamadı" }); + jobService.scheduleJob(job); return res.json(job); } catch (err) { return res.status(400).json({ message: "Job güncellenemedi", error: (err as Error).message }); @@ -45,10 +55,19 @@ router.delete("/:id", async (req, res) => { try { const job = await Job.findByIdAndDelete(id); if (!job) return res.status(404).json({ message: "Job bulunamadı" }); + jobService.clearJob(id); return res.json({ success: true }); } catch (err) { return res.status(400).json({ message: "Job silinemedi", error: (err as Error).message }); } }); +router.post("/:id/run", async (req, res) => { + const { id } = req.params; + const job = await Job.findById(id); + if (!job) return res.status(404).json({ message: "Job bulunamadı" }); + jobService.runJob(id).catch(() => undefined); + return res.json({ queued: true }); +}); + export default router; diff --git a/backend/src/services/jobService.ts b/backend/src/services/jobService.ts new file mode 100644 index 0000000..d8389e4 --- /dev/null +++ b/backend/src/services/jobService.ts @@ -0,0 +1,181 @@ +import fs from "fs"; +import path from "path"; +import { spawn } from "child_process"; +import { Server } from "socket.io"; +import { Job, JobDocument, TimeUnit } from "../models/job.js"; + +const repoBaseDir = path.join(process.cwd(), "test-runs"); + +function unitToMs(unit: TimeUnit) { + if (unit === "dakika") return 60_000; + if (unit === "saat") return 60 * 60_000; + return 24 * 60 * 60_000; +} + +function ensureDir(dir: string) { + return fs.promises.mkdir(dir, { recursive: true }); +} + +function cleanOutput(input: string) { + // ANSI escape sequences temizleme + return input.replace( + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + "" + ); +} + +function runCommand(command: string, cwd: string, onData: (chunk: string) => void) { + return new Promise((resolve, reject) => { + const child = spawn(command, { + cwd, + shell: true, + env: { ...process.env, CI: process.env.CI || "1" } + }); + + child.stdout.on("data", (data) => onData(cleanOutput(data.toString()))); + child.stderr.on("data", (data) => onData(cleanOutput(data.toString()))); + + child.on("error", (err) => { + onData(`Hata: ${err.message}`); + reject(err); + }); + + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Komut kod ${code} ile kapandı`)); + } + }); + }); +} + +async function cloneOrPull(job: JobDocument, onData: (chunk: string) => void) { + const repoDir = path.join(repoBaseDir, job._id.toString()); + await ensureDir(repoDir); + const gitDir = path.join(repoDir, ".git"); + const exists = fs.existsSync(gitDir); + + if (!exists) { + onData(`Repo klonlanıyor: ${job.repoUrl}`); + await runCommand(`git clone ${job.repoUrl} ${repoDir}`, process.cwd(), onData); + } else { + onData("Repo güncelleniyor (git pull)..."); + await runCommand("git pull", repoDir, onData); + } + + return repoDir; +} + +async function ensureDependencies(repoDir: string, onData: (chunk: string) => void) { + const nodeModules = path.join(repoDir, "node_modules"); + const hasPackageJson = fs.existsSync(path.join(repoDir, "package.json")); + if (!hasPackageJson) { + onData("package.json bulunamadı, npm install atlanıyor"); + return; + } + if (fs.existsSync(nodeModules)) { + onData("Bağımlılıklar mevcut, npm install atlanıyor"); + return; + } + onData("npm install çalıştırılıyor..."); + await runCommand("npm install", repoDir, (line) => onData(line)); +} + +class JobService { + private timers: Map = new Map(); + private io: Server | null = null; + + setSocket(io: Server) { + this.io = io; + } + + private emitStatus(jobId: string, payload: Partial) { + if (!this.io) return; + const body = { + jobId, + status: payload.status, + lastRunAt: payload.lastRunAt, + lastDurationMs: payload.lastDurationMs, + lastMessage: payload.lastMessage + }; + this.io.to(`job:${jobId}`).emit("job:status", body); + this.io.emit("job:status", body); + } + + private emitLog(jobId: string, line: string) { + if (!this.io) return; + this.io.to(`job:${jobId}`).emit("job:log", { jobId, line }); + } + + async runJob(jobId: string) { + const job = await Job.findById(jobId); + if (!job) return; + + const startedAt = Date.now(); + await Job.findByIdAndUpdate(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." }); + this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument); + + try { + const repoDir = await cloneOrPull(job, (line) => this.emitLog(jobId, line)); + await ensureDependencies(repoDir, (line) => this.emitLog(jobId, line)); + this.emitLog(jobId, `Test komutu çalıştırılıyor: ${job.testCommand}`); + await runCommand(job.testCommand, repoDir, (line) => this.emitLog(jobId, line)); + this.emitLog(jobId, "Test tamamlandı: Başarılı"); + const duration = Date.now() - startedAt; + await Job.findByIdAndUpdate(jobId, { + status: "success", + lastRunAt: new Date(), + lastDurationMs: duration, + lastMessage: "Başarılı" + }); + this.emitStatus(jobId, { + status: "success", + lastRunAt: new Date(), + lastDurationMs: duration, + lastMessage: "Başarılı" + } as JobDocument); + } catch (err) { + const duration = Date.now() - startedAt; + await Job.findByIdAndUpdate(jobId, { + status: "failed", + lastRunAt: new Date(), + lastDurationMs: duration, + lastMessage: (err as Error).message + }); + this.emitLog(jobId, `Hata: ${(err as Error).message}`); + this.emitStatus(jobId, { + status: "failed", + lastRunAt: new Date(), + lastDurationMs: duration, + lastMessage: (err as Error).message + } as JobDocument); + this.emitLog(jobId, "Test tamamlandı: Hata"); + } + } + + scheduleJob(job: JobDocument) { + const intervalMs = job.checkValue * unitToMs(job.checkUnit); + if (!intervalMs || Number.isNaN(intervalMs)) return; + + this.clearJob(job._id.toString()); + const timer = setInterval(() => this.runJob(job._id.toString()), intervalMs); + this.timers.set(job._id.toString(), timer); + } + + clearJob(jobId: string) { + const timer = this.timers.get(jobId); + if (timer) { + clearInterval(timer); + this.timers.delete(jobId); + } + } + + async bootstrap() { + const jobs = await Job.find(); + jobs.forEach((job) => this.scheduleJob(job)); + } +} + +export const jobService = new JobService(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 509c1ac..fabf857 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { ProtectedRoute } from "./components/ProtectedRoute"; import { DashboardLayout } from "./components/DashboardLayout"; import { HomePage } from "./pages/HomePage"; import { JobsPage } from "./pages/JobsPage"; +import { JobDetailPage } from "./pages/JobDetailPage"; function App() { return ( @@ -13,6 +14,7 @@ function App() { }> } /> } /> + } /> } /> diff --git a/frontend/src/api/jobs.ts b/frontend/src/api/jobs.ts index 2f49aea..d1e91ca 100644 --- a/frontend/src/api/jobs.ts +++ b/frontend/src/api/jobs.ts @@ -9,6 +9,10 @@ export interface Job { testCommand: string; checkValue: number; checkUnit: TimeUnit; + status?: "idle" | "running" | "success" | "failed"; + lastRunAt?: string; + lastDurationMs?: number; + lastMessage?: string; createdAt: string; updatedAt: string; } @@ -31,6 +35,11 @@ export async function createJob(payload: JobInput): Promise { return data as Job; } +export async function fetchJob(id: string): Promise { + const { data } = await apiClient.get(`/jobs/${id}`); + return data as Job; +} + export async function updateJob(id: string, payload: JobInput): Promise { const { data } = await apiClient.put(`/jobs/${id}`, payload); return data as Job; @@ -39,3 +48,7 @@ export async function updateJob(id: string, payload: JobInput): Promise { export async function deleteJob(id: string): Promise { await apiClient.delete(`/jobs/${id}`); } + +export async function runJob(id: string): Promise { + await apiClient.post(`/jobs/${id}/run`); +} diff --git a/frontend/src/components/RepoIcon.tsx b/frontend/src/components/RepoIcon.tsx new file mode 100644 index 0000000..f2f1bdf --- /dev/null +++ b/frontend/src/components/RepoIcon.tsx @@ -0,0 +1,21 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faGithub, faGitlab } from "@fortawesome/free-brands-svg-icons"; +import { faCodeBranch } from "@fortawesome/free-solid-svg-icons"; + +export function RepoIcon({ repoUrl }: { repoUrl: string }) { + const lower = repoUrl.toLowerCase(); + if (lower.includes("github.com")) { + return ; + } + if (lower.includes("gitlab.com")) { + return ; + } + if (lower.includes("gitea")) { + return ( +
+ Ge +
+ ); + } + return ; +} diff --git a/frontend/src/components/ui/toaster.tsx b/frontend/src/components/ui/toaster.tsx index 09e2c40..a7057d9 100644 --- a/frontend/src/components/ui/toaster.tsx +++ b/frontend/src/components/ui/toaster.tsx @@ -3,7 +3,7 @@ import { Toaster as SonnerToaster } from "sonner"; export function Toaster() { return ( = { + running: "text-amber-500", + success: "text-emerald-500", + finished: "text-emerald-500", + failed: "text-red-500", + idle: "text-muted-foreground" +}; + +export function JobDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [job, setJob] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [triggering, setTriggering] = useState(false); + const stream = useJobStream(id || ""); + const socket = useSocket(); + + useEffect(() => { + if (!id) return; + fetchJob(id) + .then(setJob) + .catch(() => setError("Job bulunamadı")) + .finally(() => setLoading(false)); + }, [id]); + + useEffect(() => { + if (!socket || !id) return; + socket.emit("job:subscribe", { jobId: id }); + return () => { + socket.emit("job:unsubscribe", { jobId: id }); + }; + }, [socket, id]); + + const statusText = useMemo(() => { + const raw = stream.status || job?.status || "idle"; + if (raw === "success") return "finished"; + return raw; + }, [stream.status, job?.status]); + + const handleRun = async () => { + if (!id) return; + setTriggering(true); + try { + await runJob(id); + } finally { + setTriggering(false); + } + }; + + return ( +
+
+ + {job && ( +
+ Durum: + + + {statusText} + + {job.lastRunAt && ( + + Son çalıştırma: {new Date(job.lastRunAt).toLocaleString()} + + )} +
+ )} + +
+ + + + {job && ( +
+ +
+ )} +
+ {job?.name || "Job Detayı"} +
+ {job?.repoUrl} + {job?.testCommand ? ` · ${job.testCommand}` : ""} + {job ? ` · ${job.checkValue} ${job.checkUnit}` : ""} +
+
+
+ + {loading &&
Yükleniyor...
} + {error &&
{error}
} + {job && ( +
+
+ Repo: + {job.repoUrl} +
+
+ Test: + {job.testCommand} +
+
+ Kontrol: + + {job.checkValue} {job.checkUnit} + +
+
+ )} +
+
+ + + + Canlı Çıktı + + +
+ {stream.logs.length === 0 && ( +
Henüz çıktı yok. Test çalıştırmaları bekleniyor.
+ )} + {stream.logs.map((line, idx) => ( +
+ {line} +
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/JobsPage.tsx b/frontend/src/pages/JobsPage.tsx index 5ca610d..ec88012 100644 --- a/frontend/src/pages/JobsPage.tsx +++ b/frontend/src/pages/JobsPage.tsx @@ -1,9 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faGithub, faGitlab } from "@fortawesome/free-brands-svg-icons"; -import { faCodeBranch, faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; +import { faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { Card, CardContent } from "../components/ui/card"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; @@ -16,6 +15,8 @@ import { } from "../components/ui/select"; import { useLiveCounter } from "../providers/live-provider"; import { createJob, deleteJob, fetchJobs, Job, JobInput, updateJob } from "../api/jobs"; +import { RepoIcon } from "../components/RepoIcon"; +import { useNavigate } from "react-router-dom"; type FormState = { _id?: string; @@ -34,26 +35,9 @@ const defaultForm: FormState = { checkUnit: "dakika" }; -function RepoIcon({ repoUrl }: { repoUrl: string }) { - const lower = repoUrl.toLowerCase(); - if (lower.includes("github.com")) { - return ; - } - if (lower.includes("gitlab.com")) { - return ; - } - if (lower.includes("gitea")) { - return ( -
- Ge -
- ); - } - return ; -} - export function JobsPage() { const { value, running } = useLiveCounter(); + const navigate = useNavigate(); const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); @@ -175,7 +159,11 @@ export function JobsPage() { )} {!loading && jobs.map((job) => ( - + navigate(`/jobs/${job._id}`)} + >
@@ -188,7 +176,10 @@ export function JobsPage() { variant="outline" size="icon" className="h-10 w-10 transition hover:bg-emerald-100" - onClick={() => handleEdit(job)} + onClick={(e) => { + e.stopPropagation(); + handleEdit(job); + }} > @@ -197,7 +188,10 @@ export function JobsPage() { size="icon" className="h-10 w-10 transition hover:bg-red-100" disabled={deletingId === job._id} - onClick={() => handleDelete(job._id)} + onClick={(e) => { + e.stopPropagation(); + handleDelete(job._id); + }} > diff --git a/frontend/src/providers/live-provider.tsx b/frontend/src/providers/live-provider.tsx index c19402c..9bd0b24 100644 --- a/frontend/src/providers/live-provider.tsx +++ b/frontend/src/providers/live-provider.tsx @@ -6,9 +6,17 @@ type LiveState = { running: boolean; }; +type JobStream = { + logs: string[]; + status?: string; + lastRunAt?: string; + lastMessage?: string; +}; + type LiveContextValue = LiveState & { startCounter: () => void; stopCounter: () => void; + jobStreams: Record; }; const LiveContext = createContext(undefined); @@ -16,6 +24,7 @@ const LiveContext = createContext(undefined); export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const socket = useSocket(); const [state, setState] = useState({ value: 0, running: false }); + const [jobStreams, setJobStreams] = useState>({}); useEffect(() => { if (!socket) return; @@ -30,14 +39,45 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children socket.on("counter:update", handleUpdate); socket.on("counter:stopped", handleStopped); + const handleJobLog = ({ jobId, line }: { jobId: string; line: string }) => { + if (!jobId) return; + setJobStreams((prev) => { + const current = prev[jobId] || { logs: [] }; + const nextLogs = [...current.logs, line].slice(-200); + return { ...prev, [jobId]: { ...current, logs: nextLogs } }; + }); + }; + + const handleJobStatus = ({ + jobId, + status, + lastRunAt, + lastMessage + }: { + jobId: string; + status?: string; + lastRunAt?: string; + lastMessage?: string; + }) => { + if (!jobId) return; + setJobStreams((prev) => { + const current = prev[jobId] || { logs: [] }; + return { ...prev, [jobId]: { ...current, status, lastRunAt, lastMessage } }; + }); + }; socket.emit("counter:status", (payload: { value: number; running: boolean }) => { setState({ value: payload.value, running: payload.running }); }); + socket.on("job:log", handleJobLog); + socket.on("job:status", handleJobStatus); + return () => { socket.off("counter:update", handleUpdate); socket.off("counter:stopped", handleStopped); + socket.off("job:log", handleJobLog); + socket.off("job:status", handleJobStatus); }; }, [socket]); @@ -60,9 +100,10 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children value: state.value, running: state.running, startCounter, - stopCounter + stopCounter, + jobStreams }), - [state, startCounter, stopCounter] + [state, startCounter, stopCounter, jobStreams] ); return {children}; @@ -73,3 +114,9 @@ export function useLiveCounter() { if (!ctx) throw new Error("useLiveCounter LiveProvider içinde kullanılmalı"); return ctx; } + +export function useJobStream(jobId: string) { + const ctx = useContext(LiveContext); + if (!ctx) throw new Error("useJobStream LiveProvider içinde kullanılmalı"); + return useMemo(() => ctx.jobStreams[jobId] || { logs: [], status: "idle" }, [ctx.jobStreams, jobId]); +}