diff --git a/backend/src/models/jobRun.ts b/backend/src/models/jobRun.ts new file mode 100644 index 0000000..6908f21 --- /dev/null +++ b/backend/src/models/jobRun.ts @@ -0,0 +1,29 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; +import { JobDocument } from "./job.js"; + +export interface JobRunDocument extends Document { + job: Types.ObjectId | JobDocument; + status: "running" | "success" | "failed"; + logs: string[]; + startedAt: Date; + finishedAt?: Date; + durationMs?: number; + createdAt: Date; + updatedAt: Date; +} + +const JobRunSchema = new Schema( + { + job: { type: Schema.Types.ObjectId, ref: "Job", required: true }, + status: { type: String, enum: ["running", "success", "failed"], required: true }, + logs: { type: [String], default: [] }, + startedAt: { type: Date, required: true }, + finishedAt: { type: Date }, + durationMs: { type: Number } + }, + { timestamps: true } +); + +JobRunSchema.index({ job: 1, startedAt: -1 }); + +export const JobRun = mongoose.model("JobRun", JobRunSchema); diff --git a/backend/src/routes/jobs.ts b/backend/src/routes/jobs.ts index 9bbb730..254a767 100644 --- a/backend/src/routes/jobs.ts +++ b/backend/src/routes/jobs.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { authMiddleware } from "../middleware/authMiddleware.js"; import { Job } from "../models/job.js"; import { jobService } from "../services/jobService.js"; +import { JobRun } from "../models/jobRun.js"; const router = Router(); @@ -16,7 +17,9 @@ 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); + const lastRun = await JobRun.findOne({ job: id }).sort({ startedAt: -1 }).lean(); + const runCount = await JobRun.countDocuments({ job: id }); + return res.json({ job, lastRun, runCount }); }); router.post("/", async (req, res) => { @@ -27,6 +30,8 @@ router.post("/", async (req, res) => { try { const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit }); jobService.scheduleJob(job); + // Yeni job oluşturulduğunda ilk test otomatik tetiklensin + jobService.runJob(job._id.toString()).catch(() => undefined); return res.status(201).json(job); } catch (err) { return res.status(400).json({ message: "Job oluşturulamadı", error: (err as Error).message }); diff --git a/backend/src/services/jobService.ts b/backend/src/services/jobService.ts index d8389e4..6afa198 100644 --- a/backend/src/services/jobService.ts +++ b/backend/src/services/jobService.ts @@ -3,6 +3,7 @@ import path from "path"; import { spawn } from "child_process"; import { Server } from "socket.io"; import { Job, JobDocument, TimeUnit } from "../models/job.js"; +import { JobRun } from "../models/jobRun.js"; const repoBaseDir = path.join(process.cwd(), "test-runs"); @@ -107,6 +108,8 @@ class JobService { private emitLog(jobId: string, line: string) { if (!this.io) return; this.io.to(`job:${jobId}`).emit("job:log", { jobId, line }); + // İlk koşu sırasında detay ekranı henüz subscribe olmamış olabilir; bu nedenle log'u yayına da gönder. + this.io.except(`job:${jobId}`).emit("job:log", { jobId, line }); } async runJob(jobId: string) { @@ -114,15 +117,27 @@ class JobService { if (!job) return; const startedAt = Date.now(); + const runLogs: string[] = []; + const pushLog = (line: string) => { + runLogs.push(line); + this.emitLog(jobId, line); + }; + + const runDoc = await JobRun.create({ + job: jobId, + status: "running", + startedAt: new Date() + }); + 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 repoDir = await cloneOrPull(job, (line) => pushLog(line)); + await ensureDependencies(repoDir, (line) => pushLog(line)); + pushLog(`Test komutu çalıştırılıyor: ${job.testCommand}`); + await runCommand(job.testCommand, repoDir, (line) => pushLog(line)); + pushLog("Test tamamlandı: Başarılı"); const duration = Date.now() - startedAt; await Job.findByIdAndUpdate(jobId, { status: "success", @@ -130,6 +145,12 @@ class JobService { lastDurationMs: duration, lastMessage: "Başarılı" }); + await JobRun.findByIdAndUpdate(runDoc._id, { + status: "success", + finishedAt: new Date(), + durationMs: duration, + logs: runLogs + }); this.emitStatus(jobId, { status: "success", lastRunAt: new Date(), @@ -144,7 +165,13 @@ class JobService { lastDurationMs: duration, lastMessage: (err as Error).message }); - this.emitLog(jobId, `Hata: ${(err as Error).message}`); + await JobRun.findByIdAndUpdate(runDoc._id, { + status: "failed", + finishedAt: new Date(), + durationMs: duration, + logs: runLogs + }); + pushLog(`Hata: ${(err as Error).message}`); this.emitStatus(jobId, { status: "failed", lastRunAt: new Date(), diff --git a/backend/test-runs/6927ea6d4eeb13c399f5384e b/backend/test-runs/6927ea6d4eeb13c399f5384e new file mode 160000 index 0000000..4167b50 --- /dev/null +++ b/backend/test-runs/6927ea6d4eeb13c399f5384e @@ -0,0 +1 @@ +Subproject commit 4167b506aaaa6d7c9302692c6a521b5dec3614fa diff --git a/backend/test-runs/6927f441d032c5edf4ce4417 b/backend/test-runs/6927f441d032c5edf4ce4417 new file mode 160000 index 0000000..4167b50 --- /dev/null +++ b/backend/test-runs/6927f441d032c5edf4ce4417 @@ -0,0 +1 @@ +Subproject commit 4167b506aaaa6d7c9302692c6a521b5dec3614fa diff --git a/backend/test-runs/6927f4801d3a3242d50578c8 b/backend/test-runs/6927f4801d3a3242d50578c8 new file mode 160000 index 0000000..4167b50 --- /dev/null +++ b/backend/test-runs/6927f4801d3a3242d50578c8 @@ -0,0 +1 @@ +Subproject commit 4167b506aaaa6d7c9302692c6a521b5dec3614fa diff --git a/backend/test-runs/6927fa601d3a3242d5057904 b/backend/test-runs/6927fa601d3a3242d5057904 new file mode 160000 index 0000000..4167b50 --- /dev/null +++ b/backend/test-runs/6927fa601d3a3242d5057904 @@ -0,0 +1 @@ +Subproject commit 4167b506aaaa6d7c9302692c6a521b5dec3614fa diff --git a/backend/test-runs/69280287fc7de8c33147ec69 b/backend/test-runs/69280287fc7de8c33147ec69 new file mode 160000 index 0000000..035d049 --- /dev/null +++ b/backend/test-runs/69280287fc7de8c33147ec69 @@ -0,0 +1 @@ +Subproject commit 035d0492040f5727f2d8a8f986744af0ba1cdbf6 diff --git a/backend/test-runs/6928185ff4e571855c319cb9 b/backend/test-runs/6928185ff4e571855c319cb9 new file mode 160000 index 0000000..035d049 --- /dev/null +++ b/backend/test-runs/6928185ff4e571855c319cb9 @@ -0,0 +1 @@ +Subproject commit 035d0492040f5727f2d8a8f986744af0ba1cdbf6 diff --git a/backend/test-runs/69281978f4e571855c319cdb b/backend/test-runs/69281978f4e571855c319cdb new file mode 160000 index 0000000..4167b50 --- /dev/null +++ b/backend/test-runs/69281978f4e571855c319cdb @@ -0,0 +1 @@ +Subproject commit 4167b506aaaa6d7c9302692c6a521b5dec3614fa diff --git a/backend/test-runs/69281d3f034039681b7f35b6 b/backend/test-runs/69281d3f034039681b7f35b6 new file mode 160000 index 0000000..4167b50 --- /dev/null +++ b/backend/test-runs/69281d3f034039681b7f35b6 @@ -0,0 +1 @@ +Subproject commit 4167b506aaaa6d7c9302692c6a521b5dec3614fa diff --git a/frontend/src/api/jobs.ts b/frontend/src/api/jobs.ts index d1e91ca..d58033f 100644 --- a/frontend/src/api/jobs.ts +++ b/frontend/src/api/jobs.ts @@ -17,6 +17,24 @@ export interface Job { updatedAt: string; } +export interface JobRun { + _id: string; + job: string; + status: "running" | "success" | "failed"; + logs: string[]; + startedAt: string; + finishedAt?: string; + durationMs?: number; + createdAt: string; + updatedAt: string; +} + +export interface JobDetailResponse { + job: Job; + lastRun?: JobRun; + runCount?: number; +} + export interface JobInput { name: string; repoUrl: string; @@ -35,9 +53,9 @@ export async function createJob(payload: JobInput): Promise { return data as Job; } -export async function fetchJob(id: string): Promise { +export async function fetchJob(id: string): Promise { const { data } = await apiClient.get(`/jobs/${id}`); - return data as Job; + return data as JobDetailResponse; } export async function updateJob(id: string, payload: JobInput): Promise { diff --git a/frontend/src/assets/gitea.png b/frontend/src/assets/gitea.png new file mode 100644 index 0000000..23bab6f Binary files /dev/null and b/frontend/src/assets/gitea.png differ diff --git a/frontend/src/assets/gitlab.png b/frontend/src/assets/gitlab.png new file mode 100644 index 0000000..91e7ba1 Binary files /dev/null and b/frontend/src/assets/gitlab.png differ diff --git a/frontend/src/components/JobStatusBadge.tsx b/frontend/src/components/JobStatusBadge.tsx new file mode 100644 index 0000000..5d5e16b --- /dev/null +++ b/frontend/src/components/JobStatusBadge.tsx @@ -0,0 +1,46 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { faCircle, faCircleCheck, faCircleNotch, faCircleXmark } from "@fortawesome/free-solid-svg-icons"; + +type Status = "idle" | "running" | "success" | "failed" | string | undefined; + +const statusStyles: Record< + "idle" | "running" | "success" | "failed", + { label: string; icon: IconDefinition; className: string; iconClassName?: string } +> = { + idle: { + label: "Idle", + icon: faCircle, + className: "bg-muted text-muted-foreground border border-border" + }, + running: { + label: "Running", + icon: faCircleNotch, + className: "bg-amber-100 text-amber-800 border border-amber-200", + iconClassName: "animate-spin" + }, + success: { + label: "Success", + icon: faCircleCheck, + className: "bg-emerald-100 text-emerald-800 border border-emerald-200" + }, + failed: { + label: "Failed", + icon: faCircleXmark, + className: "bg-red-100 text-red-800 border border-red-200" + } +}; + +export function JobStatusBadge({ status }: { status: Status }) { + const normalized = (status || "idle") as keyof typeof statusStyles; + const style = statusStyles[normalized] || statusStyles.idle; + + return ( + + + {style.label} + + ); +} diff --git a/frontend/src/components/RepoIcon.tsx b/frontend/src/components/RepoIcon.tsx index f2f1bdf..e805979 100644 --- a/frontend/src/components/RepoIcon.tsx +++ b/frontend/src/components/RepoIcon.tsx @@ -1,21 +1,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faGithub, faGitlab } from "@fortawesome/free-brands-svg-icons"; +import { faGithub } from "@fortawesome/free-brands-svg-icons"; import { faCodeBranch } from "@fortawesome/free-solid-svg-icons"; +import giteaLogo from "../assets/gitea.png"; +import gitlabLogo from "../assets/gitlab.png"; 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 -
- ); - } + if (lower.includes("github.com")) return ; + if (lower.includes("gitlab")) return GitLab; + if (lower.includes("gitea")) return Gitea; return ; } diff --git a/frontend/src/pages/JobDetailPage.tsx b/frontend/src/pages/JobDetailPage.tsx index 2fe89e0..bc43f04 100644 --- a/frontend/src/pages/JobDetailPage.tsx +++ b/frontend/src/pages/JobDetailPage.tsx @@ -1,36 +1,45 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faArrowLeft, faCircle } from "@fortawesome/free-solid-svg-icons"; +import { + faArrowLeft, + faCircleCheck, + faCircleExclamation, + faClock +} from "@fortawesome/free-solid-svg-icons"; import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; import { Button } from "../components/ui/button"; import { RepoIcon } from "../components/RepoIcon"; -import { fetchJob, Job, runJob } from "../api/jobs"; +import { fetchJob, Job, JobRun, runJob } from "../api/jobs"; import { useJobStream } from "../providers/live-provider"; import { useSocket } from "../providers/socket-provider"; - -const statusColor: Record = { - running: "text-amber-500", - success: "text-emerald-500", - finished: "text-emerald-500", - failed: "text-red-500", - idle: "text-muted-foreground" -}; +import { JobStatusBadge } from "../components/JobStatusBadge"; +import { faListCheck } from "@fortawesome/free-solid-svg-icons"; export function JobDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [job, setJob] = useState(null); + const [lastRun, setLastRun] = useState(null); + const [runCount, setRunCount] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [triggering, setTriggering] = useState(false); + const [countdown, setCountdown] = useState("00:00:00"); const stream = useJobStream(id || ""); const socket = useSocket(); + const logEndRef = useRef(null); + const prevStatusRef = useRef(undefined); + const currentLogs = stream.logs.length > 0 ? stream.logs : lastRun?.logs || []; useEffect(() => { if (!id) return; fetchJob(id) - .then(setJob) + .then((data) => { + setJob(data.job); + setLastRun(data.lastRun || null); + setRunCount(data.runCount || 0); + }) .catch(() => setError("Job bulunamadı")) .finally(() => setLoading(false)); }, [id]); @@ -43,11 +52,62 @@ export function JobDetailPage() { }; }, [socket, id]); - const statusText = useMemo(() => { - const raw = stream.status || job?.status || "idle"; - if (raw === "success") return "finished"; - return raw; - }, [stream.status, job?.status]); + useEffect(() => { + if (!stream.status) return; + const prev = prevStatusRef.current; + prevStatusRef.current = stream.status; + if (stream.status === "success" || stream.status === "failed") { + setLastRun((prevRun) => ({ + _id: prevRun?._id || `stream-${Date.now()}`, + job: prevRun?.job || job?._id || "", + status: stream.status, + logs: stream.logs, + startedAt: prevRun?.startedAt || new Date().toISOString(), + finishedAt: new Date().toISOString(), + durationMs: prevRun?.durationMs, + createdAt: prevRun?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString() + })); + if (prev !== stream.status) { + setRunCount((c) => c + 1); + } + } + }, [stream.status, stream.logs, job?._id]); + + const lastRunAt = useMemo( + () => stream.lastRunAt || job?.lastRunAt || lastRun?.finishedAt || lastRun?.startedAt, + [stream.lastRunAt, job?.lastRunAt, lastRun?.finishedAt, lastRun?.startedAt] + ); + const effectiveStatus = useMemo( + () => (stream.status || job?.status || lastRun?.status || "idle") as Job["status"], + [stream.status, job?.status, lastRun?.status] + ); + + const testSummary = useMemo(() => { + const logs = currentLogs; + const reverseLogs = [...logs].reverse(); + const lastRunMarkerIndex = reverseLogs.findIndex((l) => + l.toLowerCase().includes("test komutu çalıştırılıyor") + ); + const lastRunStart = lastRunMarkerIndex === -1 ? 0 : logs.length - lastRunMarkerIndex - 1; + const recent = logs.slice(lastRunStart); + const reversed = [...recent].reverse(); + + const findPrefix = (prefix: string) => + reversed.find((l) => l.trim().toLowerCase().startsWith(prefix.toLowerCase())) || ""; + const findMatch = (regex: RegExp) => reversed.find((l) => regex.test(l)) || ""; + + const startAtLine = findPrefix("start at"); + const fallbackStart = !startAtLine && lastRunAt ? `Start at ${new Date(lastRunAt).toLocaleString()}` : ""; + + return { + startAt: startAtLine || fallbackStart, + testFiles: findPrefix("test files"), + tests: findPrefix("tests"), + passing: findMatch(/\bpassing\b/i), + failing: findMatch(/\bfailing\b/i) + }; + }, [currentLogs, lastRunAt]); const handleRun = async () => { if (!id) return; @@ -59,6 +119,44 @@ export function JobDetailPage() { } }; + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [currentLogs]); + + useEffect(() => { + const finishedAt = lastRun?.finishedAt || stream.lastRunAt || job?.lastRunAt; + if (!job || !job.checkUnit || !job.checkValue || !finishedAt) { + setCountdown("00:00:00"); + return; + } + const intervalMs = + job.checkUnit === "dakika" + ? job.checkValue * 60 * 1000 + : job.checkUnit === "saat" + ? job.checkValue * 60 * 60 * 1000 + : job.checkValue * 24 * 60 * 60 * 1000; + const target = new Date(finishedAt).getTime() + intervalMs; + const format = (ms: number) => { + const clamped = Math.max(ms, 0); + const totalSec = Math.floor(clamped / 1000); + const h = String(Math.floor(totalSec / 3600)).padStart(2, "0"); + const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0"); + const s = String(totalSec % 60).padStart(2, "0"); + return `${h}:${m}:${s}`; + }; + const tick = () => setCountdown(format(target - Date.now())); + tick(); + let timer: NodeJS.Timeout | null = null; + const delayStart = setTimeout(() => { + tick(); + timer = setInterval(tick, 1000); + }, 3000); + return () => { + clearTimeout(delayStart); + if (timer) clearInterval(timer); + }; + }, [job?.checkUnit, job?.checkValue, lastRun?.finishedAt, stream.lastRunAt, job?.lastRunAt]); + return (
@@ -66,20 +164,6 @@ export function JobDetailPage() { Geri - {job && ( -
- Durum: - - - {statusText} - - {job.lastRunAt && ( - - Son çalıştırma: {new Date(job.lastRunAt).toLocaleString()} - - )} -
- )} @@ -92,20 +176,19 @@ export function JobDetailPage() {
)} -
+
{job?.name || "Job Detayı"} -
- {job?.repoUrl} - {job?.testCommand ? ` · ${job.testCommand}` : ""} - {job ? ` · ${job.checkValue} ${job.checkUnit}` : ""} -
+ + + {runCount} test +
{loading &&
Yükleniyor...
} {error &&
{error}
} {job && ( -
+
Repo: {job.repoUrl} @@ -114,31 +197,96 @@ export function JobDetailPage() { Test: {job.testCommand}
-
+
Kontrol: {job.checkValue} {job.checkUnit} + · + + {countdown} + +
+
+ Status: + +
+
+ Last Check: + + {lastRunAt ? new Date(lastRunAt).toLocaleString() : "Henüz çalıştırılmadı"} +
)} + + + Test Özeti + + +
+
+ {testSummary.testFiles ? ( +
+ + {testSummary.testFiles} +
+ ) : null} + + {testSummary.tests ? ( +
+ + {testSummary.tests} +
+ ) : null} + + {testSummary.passing ? ( +
+ + {testSummary.passing} +
+ ) : null} + + {testSummary.failing ? ( +
+ + {testSummary.failing} +
+ ) : null} + + {testSummary.startAt ? ( +
+ + {testSummary.startAt} +
+ ) : null} + + {!testSummary.passing && !testSummary.failing && !testSummary.startAt && ( +
Özet üretmek için henüz yeterli çıktı yok.
+ )} +
+
+
+
+ Canlı Çıktı
- {stream.logs.length === 0 && ( + {currentLogs.length === 0 && (
Henüz çıktı yok. Test çalıştırmaları bekleniyor.
)} - {stream.logs.map((line, idx) => ( + {currentLogs.map((line, idx) => (
{line}
))} +
diff --git a/frontend/src/pages/JobsPage.tsx b/frontend/src/pages/JobsPage.tsx index ec88012..1fbdb25 100644 --- a/frontend/src/pages/JobsPage.tsx +++ b/frontend/src/pages/JobsPage.tsx @@ -17,6 +17,7 @@ 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"; +import { JobStatusBadge } from "../components/JobStatusBadge"; type FormState = { _id?: string; @@ -36,7 +37,7 @@ const defaultForm: FormState = { }; export function JobsPage() { - const { value, running } = useLiveCounter(); + const { value, running, jobStreams } = useLiveCounter(); const navigate = useNavigate(); const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(false); @@ -108,6 +109,8 @@ export function JobsPage() { }; const handleDelete = async (id: string) => { + const ok = window.confirm("Bu job'ı silmek istediğinize emin misiniz?"); + if (!ok) return; setDeletingId(id); try { await deleteJob(id); @@ -158,7 +161,10 @@ export function JobsPage() {
)} {!loading && - jobs.map((job) => ( + jobs.map((job) => { + const stream = jobStreams[job._id]; + const status = stream?.status || job.status || "idle"; + return (
-
-
{job.name}
+
+
+
{job.name}
+ +
{modalOpen && ( diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 11f02fe..96c80f1 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1 +1,11 @@ /// + +declare module "*.svg" { + const src: string; + export default src; +} + +declare module "*.png" { + const src: string; + export default src; +}