diff --git a/backend/src/routes/jobs.ts b/backend/src/routes/jobs.ts index 254a767..85187d7 100644 --- a/backend/src/routes/jobs.ts +++ b/backend/src/routes/jobs.ts @@ -10,7 +10,19 @@ router.use(authMiddleware); router.get("/", async (_req, res) => { const jobs = await Job.find().sort({ createdAt: -1 }).lean(); - res.json(jobs); + const counts = await JobRun.aggregate([ + { $group: { _id: "$job", runCount: { $sum: 1 } } } + ]); + const countMap = counts.reduce>((acc, item) => { + acc[item._id.toString()] = item.runCount; + return acc; + }, {}); + res.json( + jobs.map((job) => ({ + ...job, + runCount: countMap[job._id.toString()] || 0 + })) + ); }); router.get("/:id", async (req, res) => { diff --git a/backend/src/services/jobService.ts b/backend/src/services/jobService.ts index 6afa198..cecb464 100644 --- a/backend/src/services/jobService.ts +++ b/backend/src/services/jobService.ts @@ -92,14 +92,16 @@ class JobService { this.io = io; } - private emitStatus(jobId: string, payload: Partial) { + private async emitStatus(jobId: string, payload: Partial) { if (!this.io) return; + const runCount = await JobRun.countDocuments({ job: jobId }); const body = { jobId, status: payload.status, lastRunAt: payload.lastRunAt, lastDurationMs: payload.lastDurationMs, - lastMessage: payload.lastMessage + lastMessage: payload.lastMessage, + runCount }; this.io.to(`job:${jobId}`).emit("job:status", body); this.io.emit("job:status", body); @@ -130,7 +132,7 @@ class JobService { }); 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); + await this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument); try { const repoDir = await cloneOrPull(job, (line) => pushLog(line)); @@ -151,7 +153,7 @@ class JobService { durationMs: duration, logs: runLogs }); - this.emitStatus(jobId, { + await this.emitStatus(jobId, { status: "success", lastRunAt: new Date(), lastDurationMs: duration, @@ -172,7 +174,7 @@ class JobService { logs: runLogs }); pushLog(`Hata: ${(err as Error).message}`); - this.emitStatus(jobId, { + await this.emitStatus(jobId, { status: "failed", lastRunAt: new Date(), lastDurationMs: duration, diff --git a/frontend/src/api/jobs.ts b/frontend/src/api/jobs.ts index d58033f..dfe25ed 100644 --- a/frontend/src/api/jobs.ts +++ b/frontend/src/api/jobs.ts @@ -13,6 +13,7 @@ export interface Job { lastRunAt?: string; lastDurationMs?: number; lastMessage?: string; + runCount?: number; createdAt: string; updatedAt: string; } diff --git a/frontend/src/components/RepoIcon.tsx b/frontend/src/components/RepoIcon.tsx index e805979..f476191 100644 --- a/frontend/src/components/RepoIcon.tsx +++ b/frontend/src/components/RepoIcon.tsx @@ -6,8 +6,8 @@ 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")) return GitLab; - if (lower.includes("gitea")) return Gitea; - return ; + 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 bc43f04..6540fb7 100644 --- a/frontend/src/pages/JobDetailPage.tsx +++ b/frontend/src/pages/JobDetailPage.tsx @@ -5,16 +5,30 @@ import { faArrowLeft, faCircleCheck, faCircleExclamation, - faClock + faClock, + faPen, + faTrash } 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, JobRun, runJob } from "../api/jobs"; +import { deleteJob, fetchJob, Job, JobInput, JobRun, runJob, updateJob } from "../api/jobs"; import { useJobStream } from "../providers/live-provider"; import { useSocket } from "../providers/socket-provider"; import { JobStatusBadge } from "../components/JobStatusBadge"; import { faListCheck } from "@fortawesome/free-solid-svg-icons"; +import { toast } from "sonner"; +import { Input } from "../components/ui/input"; +import { Label } from "../components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; + +type FormState = { + name: string; + repoUrl: string; + testCommand: string; + checkValue: string; + checkUnit: JobInput["checkUnit"]; +}; export function JobDetailPage() { const { id } = useParams<{ id: string }>(); @@ -26,12 +40,27 @@ export function JobDetailPage() { const [error, setError] = useState(null); const [triggering, setTriggering] = useState(false); const [countdown, setCountdown] = useState("00:00:00"); + const [editOpen, setEditOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState({ + name: "", + repoUrl: "", + testCommand: "", + checkValue: "", + checkUnit: "dakika" + }); 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 (stream.runCount !== undefined) { + setRunCount(stream.runCount); + } + }, [stream.runCount]); + useEffect(() => { if (!id) return; fetchJob(id) @@ -39,6 +68,13 @@ export function JobDetailPage() { setJob(data.job); setLastRun(data.lastRun || null); setRunCount(data.runCount || 0); + setForm({ + name: data.job.name, + repoUrl: data.job.repoUrl, + testCommand: data.job.testCommand, + checkValue: String(data.job.checkValue), + checkUnit: data.job.checkUnit + }); }) .catch(() => setError("Job bulunamadı")) .finally(() => setLoading(false)); @@ -114,11 +150,74 @@ export function JobDetailPage() { setTriggering(true); try { await runJob(id); + setRunCount((c) => c + 1); } finally { setTriggering(false); } }; + const handleEdit = () => { + if (!job) return; + setForm({ + name: job.name, + repoUrl: job.repoUrl, + testCommand: job.testCommand, + checkValue: String(job.checkValue), + checkUnit: job.checkUnit + }); + setEditOpen(true); + }; + + const handleDelete = async () => { + if (!job?._id) return; + const ok = window.confirm("Bu job'ı silmek istediğinize emin misiniz?"); + if (!ok) return; + try { + await deleteJob(job._id); + toast.success("Job silindi"); + navigate("/jobs", { replace: true }); + } catch (err) { + toast.error("Job silinemedi"); + } + }; + + const handleSave = async () => { + if (!job?._id) return; + setSaving(true); + try { + const payload: JobInput = { + name: form.name, + repoUrl: form.repoUrl, + testCommand: form.testCommand, + checkValue: Number(form.checkValue), + checkUnit: form.checkUnit + }; + + if (!payload.name || !payload.repoUrl || !payload.testCommand || !payload.checkValue) { + toast.error("Tüm alanları doldurun"); + setSaving(false); + return; + } + await updateJob(job._id, payload); + toast.success("Job güncellendi"); + setJob((prev) => + prev + ? { + ...prev, + ...payload + } + : prev + ); + setEditOpen(false); + await runJob(job._id); + setRunCount((c) => c + 1); + } catch { + toast.error("Güncelleme başarısız"); + } finally { + setSaving(false); + } + }; + useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [currentLogs]); @@ -164,15 +263,35 @@ export function JobDetailPage() { Geri - +
+ + + +
{job && ( -
+
)} @@ -219,8 +338,92 @@ export function JobDetailPage() {
)} - -
+ + + + {editOpen && ( +
+
+
+
+
Job Güncelle
+
Değişiklikler kaydedildiğinde test yeniden tetiklenecek.
+
+ +
+
+
+ + setForm((prev) => ({ ...prev, name: e.target.value }))} + placeholder="Nightly E2E" + required + /> +
+
+ + setForm((prev) => ({ ...prev, repoUrl: e.target.value }))} + placeholder="https://github.com/org/repo" + required + /> +
+
+ + setForm((prev) => ({ ...prev, testCommand: e.target.value }))} + placeholder="npm test" + required + /> +
+
+ +
+ setForm((prev) => ({ ...prev, checkValue: e.target.value }))} + /> + +
+
+
+
+ + +
+
+
+ )} @@ -274,7 +477,7 @@ export function JobDetailPage() { - Canlı Çıktı + Logs
diff --git a/frontend/src/pages/JobsPage.tsx b/frontend/src/pages/JobsPage.tsx index 1fbdb25..4ed2281 100644 --- a/frontend/src/pages/JobsPage.tsx +++ b/frontend/src/pages/JobsPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faListCheck, 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"; @@ -18,6 +18,7 @@ import { createJob, deleteJob, fetchJobs, Job, JobInput, updateJob } from "../ap import { RepoIcon } from "../components/RepoIcon"; import { useNavigate } from "react-router-dom"; import { JobStatusBadge } from "../components/JobStatusBadge"; +import { useLocation } from "react-router-dom"; type FormState = { _id?: string; @@ -39,12 +40,15 @@ const defaultForm: FormState = { export function JobsPage() { const { value, running, jobStreams } = useLiveCounter(); const navigate = useNavigate(); + const location = useLocation(); const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [form, setForm] = useState(defaultForm); const [saving, setSaving] = useState(false); const [deletingId, setDeletingId] = useState(null); + const [now, setNow] = useState(() => Date.now()); + const [pendingEditId, setPendingEditId] = useState(null); const isEdit = useMemo(() => !!form._id, [form._id]); @@ -64,6 +68,46 @@ export function JobsPage() { loadJobs(); }, []); + useEffect(() => { + const state = location.state as { editJobId?: string } | null; + if (state?.editJobId) { + setPendingEditId(state.editJobId); + navigate(location.pathname, { replace: true }); + } + }, [location.state, navigate, location.pathname]); + + useEffect(() => { + if (!pendingEditId || jobs.length === 0) return; + const job = jobs.find((j) => j._id === pendingEditId); + if (job) { + handleEdit(job); + setPendingEditId(null); + } + }, [pendingEditId, jobs]); + + useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(interval); + }, []); + + const formatCountdown = (job: Job, stream: ReturnType["jobStreams"][string]) => { + const lastRunAt = stream?.lastRunAt || job.lastRunAt; + if (!lastRunAt) return "00:00:00"; + const baseMs = + 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(lastRunAt).getTime() + baseMs; + const diff = Math.max(target - now, 0); + const totalSec = Math.floor(diff / 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 handleOpenNew = () => { setForm(defaultForm); setModalOpen(true); @@ -93,11 +137,15 @@ export function JobsPage() { if (isEdit && form._id) { const updated = await updateJob(form._id, payload); - setJobs((prev) => prev.map((j) => (j._id === updated._id ? updated : j))); + setJobs((prev) => + prev.map((j) => + j._id === updated._id ? { ...updated, runCount: j.runCount ?? updated.runCount } : j + ) + ); toast.success("Job güncellendi"); } else { const created = await createJob(payload); - setJobs((prev) => [created, ...prev]); + setJobs((prev) => [{ ...created, runCount: created.runCount ?? 0 }, ...prev]); toast.success("Job oluşturuldu"); } setModalOpen(false); @@ -164,6 +212,8 @@ export function JobsPage() { jobs.map((job) => { const stream = jobStreams[job._id]; const status = stream?.status || job.status || "idle"; + const runCount = stream?.runCount ?? job.runCount ?? 0; + const countdown = formatCountdown(job, stream); return (
-
+
{job.name}
+ + + {runCount}x +
diff --git a/frontend/src/providers/live-provider.tsx b/frontend/src/providers/live-provider.tsx index 9bd0b24..d5aa5a1 100644 --- a/frontend/src/providers/live-provider.tsx +++ b/frontend/src/providers/live-provider.tsx @@ -11,6 +11,7 @@ type JobStream = { status?: string; lastRunAt?: string; lastMessage?: string; + runCount?: number; }; type LiveContextValue = LiveState & { @@ -52,17 +53,19 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children jobId, status, lastRunAt, - lastMessage + lastMessage, + runCount }: { jobId: string; status?: string; lastRunAt?: string; lastMessage?: string; + runCount?: number; }) => { if (!jobId) return; setJobStreams((prev) => { const current = prev[jobId] || { logs: [] }; - return { ...prev, [jobId]: { ...current, status, lastRunAt, lastMessage } }; + return { ...prev, [jobId]: { ...current, status, lastRunAt, lastMessage, runCount } }; }); }; @@ -118,5 +121,8 @@ export function useLiveCounter() { 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]); + return useMemo( + () => ctx.jobStreams[jobId] || { logs: [], status: "idle", runCount: 0 }, + [ctx.jobStreams, jobId] + ); }