From eef82577ab3d91c1e97a7a23f52b08fde8171f00 Mon Sep 17 00:00:00 2001 From: sbilketay Date: Thu, 27 Nov 2025 17:26:53 +0300 Subject: [PATCH] Update --- backend/src/index.ts | 37 ---- backend/src/routes/jobs.ts | 46 ++++ backend/src/services/jobService.ts | 11 +- docs/PROJE_DOKUMANTASYONU.md | 7 +- frontend/package.json | 1 + frontend/src/api/jobs.ts | 24 ++ frontend/src/components/DashboardLayout.tsx | 5 +- frontend/src/pages/HomePage.tsx | 232 +++++++++++++++++--- frontend/src/pages/JobDetailPage.tsx | 18 +- frontend/src/pages/JobsPage.tsx | 55 ++--- frontend/src/providers/live-provider.tsx | 57 +---- 11 files changed, 333 insertions(+), 160 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index bae4e45..261821a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -38,29 +38,6 @@ const io = new Server(server, { jobService.setSocket(io); -let counter = 0; -let counterTimer: NodeJS.Timeout | null = null; - -const broadcastCounter = () => { - io.emit("counter:update", { value: counter }); -}; - -const startCounter = () => { - if (counterTimer) return; - counterTimer = setInterval(() => { - counter += 1; - broadcastCounter(); - }, 1000); -}; - -const stopCounter = () => { - if (counterTimer) { - clearInterval(counterTimer); - counterTimer = null; - } - io.emit("counter:stopped", { value: counter }); -}; - io.use((socket, next) => { const token = socket.handshake.auth?.token as string | undefined; if (!token) { @@ -81,20 +58,6 @@ io.on("connection", (socket) => { socket.emit("pong", "pong"); }); - socket.on("counter:start", (ack?: (payload: { running: boolean; value: number }) => void) => { - startCounter(); - ack?.({ running: true, value: counter }); - }); - - socket.on("counter:stop", (ack?: (payload: { running: boolean; value: number }) => void) => { - stopCounter(); - ack?.({ running: false, value: counter }); - }); - - 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}`); diff --git a/backend/src/routes/jobs.ts b/backend/src/routes/jobs.ts index 85187d7..8ddda64 100644 --- a/backend/src/routes/jobs.ts +++ b/backend/src/routes/jobs.ts @@ -25,6 +25,52 @@ router.get("/", async (_req, res) => { ); }); +router.get("/metrics/summary", async (_req, res) => { + const since = new Date(); + since.setDate(since.getDate() - 7); + + const dailyStats = await JobRun.aggregate([ + { $match: { startedAt: { $gte: since } } }, + { + $group: { + _id: { $dateToString: { format: "%Y-%m-%d", date: "$startedAt" } }, + total: { $sum: 1 }, + success: { + $sum: { + $cond: [{ $eq: ["$status", "success"] }, 1, 0] + } + }, + failed: { + $sum: { + $cond: [{ $eq: ["$status", "failed"] }, 1, 0] + } + }, + avgDurationMs: { $avg: "$durationMs" } + } + }, + { $sort: { _id: 1 } } + ]); + + const recentRuns = await JobRun.find() + .sort({ startedAt: -1 }) + .limit(10) + .populate("job", "name repoUrl status") + .lean(); + + const successTotal = dailyStats.reduce((acc, d) => acc + (d.success || 0), 0); + const totalRuns = dailyStats.reduce((acc, d) => acc + (d.total || 0), 0); + const successRate = totalRuns ? Math.round((successTotal / totalRuns) * 100) : 0; + + return res.json({ + dailyStats, + recentRuns, + totals: { + successRate, + totalRuns + } + }); +}); + router.get("/:id", async (req, res) => { const { id } = req.params; const job = await Job.findById(id).lean(); diff --git a/backend/src/services/jobService.ts b/backend/src/services/jobService.ts index cecb464..271cfea 100644 --- a/backend/src/services/jobService.ts +++ b/backend/src/services/jobService.ts @@ -34,8 +34,15 @@ function runCommand(command: string, cwd: string, onData: (chunk: string) => voi 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()))); + const emitLines = (chunk: Buffer) => { + const cleaned = cleanOutput(chunk.toString()).replace(/\r\n|\r/g, "\n"); + cleaned.split("\n").forEach((line) => { + onData(line); + }); + }; + + child.stdout.on("data", emitLines); + child.stderr.on("data", emitLines); child.on("error", (err) => { onData(`Hata: ${err.message}`); diff --git a/docs/PROJE_DOKUMANTASYONU.md b/docs/PROJE_DOKUMANTASYONU.md index 2a039c5..7fe9247 100644 --- a/docs/PROJE_DOKUMANTASYONU.md +++ b/docs/PROJE_DOKUMANTASYONU.md @@ -352,17 +352,12 @@ Job'ı manuel olarak çalıştırır. #### client → server - `ping` - Bağlantı kontrolü -- `counter:start` - Sayaç başlatma -- `counter:stop` - Sayaç durdurma -- `counter:status` - Sayaç durumu sorgula - `job:subscribe` - Job durum takibi - `job:unsubscribe` - Job durum takibi bırakma #### server → client - `hello` - Bağlantı kurulduğunda - `pong` - Ping yanıtı -- `counter:update` - Sayaç değeri güncellemesi -- `counter:stopped` - Sayaç durduğunda - `job:status` - Job durumu güncellemesi - `job:log` - Job log mesajı @@ -556,4 +551,4 @@ JobService, belirlenen aralıklarla otomatik test çalıştırır: --- -*Bu dökümantasyon Wisecolt-CI projesinin tüm özelliklerini ve kullanımını kapsamaktadır. Geliştirme sürecinde güncel tutulmalıdır.* \ No newline at end of file +*Bu dökümantasyon Wisecolt-CI projesinin tüm özelliklerini ve kullanımını kapsamaktadır. Geliştirme sürecinde güncel tutulmalıdır.* diff --git a/frontend/package.json b/frontend/package.json index 6426615..0f15841 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.21.0", + "recharts": "^3.5.0", "socket.io-client": "^4.7.2", "sonner": "^1.4.0", "tailwind-merge": "^1.14.0" diff --git a/frontend/src/api/jobs.ts b/frontend/src/api/jobs.ts index dfe25ed..ee9847c 100644 --- a/frontend/src/api/jobs.ts +++ b/frontend/src/api/jobs.ts @@ -30,6 +30,10 @@ export interface JobRun { updatedAt: string; } +export interface JobRunWithJob extends Omit { + job: Job; +} + export interface JobDetailResponse { job: Job; lastRun?: JobRun; @@ -71,3 +75,23 @@ export async function deleteJob(id: string): Promise { export async function runJob(id: string): Promise { await apiClient.post(`/jobs/${id}/run`); } + +export interface JobMetrics { + dailyStats: Array<{ + _id: string; + total: number; + success: number; + failed: number; + avgDurationMs?: number; + }>; + recentRuns: JobRunWithJob[]; + totals: { + successRate: number; + totalRuns: number; + }; +} + +export async function fetchJobMetrics(): Promise { + const { data } = await apiClient.get("/jobs/metrics/summary"); + return data as JobMetrics; +} diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index a977611..49409dc 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -29,7 +29,7 @@ export function DashboardLayout() { return (
-
-
+
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 03db6fa..a3fb836 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,40 +1,212 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Line, + LineChart, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Legend +} from "recharts"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; -import { Button } from "../components/ui/button"; -import { useLiveCounter } from "../providers/live-provider"; +import { useLiveData } from "../providers/live-provider"; +import { fetchJobMetrics, JobMetrics } from "../api/jobs"; +import { JobStatusBadge } from "../components/JobStatusBadge"; +import { RepoIcon } from "../components/RepoIcon"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faClockRotateLeft, faListCheck } from "@fortawesome/free-solid-svg-icons"; + +function formatDuration(ms?: number) { + if (!ms || Number.isNaN(ms)) return "-"; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const rem = seconds % 60; + if (minutes < 60) return `${minutes}dk ${rem}s`; + const hours = Math.floor(minutes / 60); + return `${hours}sa ${minutes % 60}dk`; +} export function HomePage() { - const { value, running, startCounter, stopCounter } = useLiveCounter(); + const [metrics, setMetrics] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { jobStreams } = useLiveData(); + const navigate = useNavigate(); + + useEffect(() => { + fetchJobMetrics() + .then(setMetrics) + .catch(() => setError("Metrikler alınamadı")) + .finally(() => setLoading(false)); + }, []); + + const chartData = useMemo(() => { + if (!metrics) return []; + return metrics.dailyStats.map((d) => ({ + date: d._id, + Başarılı: d.success, + Hatalı: d.failed + })); + }, [metrics]); + + const mergedRuns = useMemo(() => { + if (!metrics) return []; + return metrics.recentRuns.map((run, idx) => { + const live = jobStreams[run.job._id]; + // Sadece en güncel run (liste başı) canlı güncellemeleri alır; geçmiş kayıtlar sabit kalır. + if (idx === 0 && live?.status) { + return { + ...run, + status: live.status, + finishedAt: live.lastRunAt || run.finishedAt, + durationMs: live.lastDurationMs ?? run.durationMs + }; + } + return run; + }); + }, [metrics, jobStreams]); + + const lastRun = mergedRuns[0]; + const lastRunDuration = formatDuration(lastRun?.durationMs); return ( - - - Canlı Sayaç - - Sayaç sunucu tarafından çalışır; başlattıktan sonra diğer sayfalardan anlık izleyebilirsiniz. - - - -
-
-
Durum
-
- {running ? "Çalışıyor" : "Beklemede"} +
+
+ + + Son Çalıştırma + Son 10 çalıştırmanın en günceli +
+ Başarı oranı:{" "} + {metrics?.totals.successRate ?? 0}%
+
+ + {loading &&
Yükleniyor...
} + {error &&
{error}
} + {!loading && lastRun && ( +
+
+ +
+
{lastRun.job.name}
+
+ {new Date(lastRun.startedAt).toLocaleString()} +
+
+
+
+ +
+ Süre: {lastRunDuration} +
+
+
+ )} + {!loading && !lastRun &&
Henüz kayıt yok.
} +
+
+
+ +
+ + +
+ Son 7 Gün Çalıştırma Trendleri + Başarılı / Hatalı job sayıları +
+
+ + {metrics?.totals.totalRuns ?? 0} toplam koşu +
+
+ + {chartData.length === 0 ? ( +
Grafik verisi yok.
+ ) : ( + + + + + + + + + + + + )} +
+
+ + + + Hızlı Metrikler + Özet görünüm + + +
+ Başarı Oranı + + {metrics?.totals.successRate ?? 0}% + +
+
+ Toplam Çalıştırma + + {metrics?.totals.totalRuns ?? 0} + +
+
+ Son Süre + {lastRunDuration} +
+
+
+
+ + + +
+ Etkinlik Akışı + Son 10 job çalıştırması
-
-
Değer
-
{value}
+
+ + {mergedRuns.length ?? 0} kayıt
-
-
- - -
- -
+ + + {loading &&
Yükleniyor...
} + {error &&
{error}
} + {!loading && mergedRuns.length === 0 && ( +
Henüz çalıştırma yok.
+ )} + {!loading && + mergedRuns.map((run) => ( + + ))} +
+ +
); } diff --git a/frontend/src/pages/JobDetailPage.tsx b/frontend/src/pages/JobDetailPage.tsx index 6540fb7..c98dcbf 100644 --- a/frontend/src/pages/JobDetailPage.tsx +++ b/frontend/src/pages/JobDetailPage.tsx @@ -148,6 +148,7 @@ export function JobDetailPage() { const handleRun = async () => { if (!id) return; setTriggering(true); + setCountdown("00:00:00"); try { await runJob(id); setRunCount((c) => c + 1); @@ -223,6 +224,11 @@ export function JobDetailPage() { }, [currentLogs]); useEffect(() => { + if (effectiveStatus === "running") { + setCountdown("00:00:00"); + return; + } + const finishedAt = lastRun?.finishedAt || stream.lastRunAt || job?.lastRunAt; if (!job || !job.checkUnit || !job.checkValue || !finishedAt) { setCountdown("00:00:00"); @@ -270,6 +276,8 @@ export function JobDetailPage() { className="h-10 w-10 transition hover:bg-emerald-100" onClick={handleEdit} disabled={!job} + title="Job'ı düzenle" + aria-label="Job'ı düzenle" > @@ -279,10 +287,18 @@ export function JobDetailPage() { className="h-10 w-10 transition hover:bg-red-100" onClick={handleDelete} disabled={!job} + title="Job'ı sil" + aria-label="Job'ı sil" > -
diff --git a/frontend/src/pages/JobsPage.tsx b/frontend/src/pages/JobsPage.tsx index 4ed2281..f9ff775 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 { faListCheck, faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faListCheck, faPen, faPlay, faPlus } 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"; @@ -13,8 +13,8 @@ import { SelectTrigger, SelectValue } from "../components/ui/select"; -import { useLiveCounter } from "../providers/live-provider"; -import { createJob, deleteJob, fetchJobs, Job, JobInput, updateJob } from "../api/jobs"; +import { useLiveData } from "../providers/live-provider"; +import { createJob, fetchJobs, Job, JobInput, runJob, updateJob } from "../api/jobs"; import { RepoIcon } from "../components/RepoIcon"; import { useNavigate } from "react-router-dom"; import { JobStatusBadge } from "../components/JobStatusBadge"; @@ -38,7 +38,7 @@ const defaultForm: FormState = { }; export function JobsPage() { - const { value, running, jobStreams } = useLiveCounter(); + const { jobStreams } = useLiveData(); const navigate = useNavigate(); const location = useLocation(); const [jobs, setJobs] = useState([]); @@ -46,9 +46,9 @@ export function JobsPage() { 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 [runningId, setRunningId] = useState(null); const isEdit = useMemo(() => !!form._id, [form._id]); @@ -90,7 +90,10 @@ export function JobsPage() { return () => clearInterval(interval); }, []); - const formatCountdown = (job: Job, stream: ReturnType["jobStreams"][string]) => { + const formatCountdown = (job: Job, stream: ReturnType["jobStreams"][string]) => { + if (stream?.status === "running") { + return "00:00:00"; + } const lastRunAt = stream?.lastRunAt || job.lastRunAt; if (!lastRunAt) return "00:00:00"; const baseMs = @@ -156,18 +159,15 @@ export function JobsPage() { } }; - const handleDelete = async (id: string) => { - const ok = window.confirm("Bu job'ı silmek istediğinize emin misiniz?"); - if (!ok) return; - setDeletingId(id); + const handleRun = async (id: string) => { + setRunningId(id); try { - await deleteJob(id); - setJobs((prev) => prev.filter((j) => j._id !== id)); - toast.success("Job silindi"); - } catch (err) { - toast.error("Silme sırasında hata oluştu"); + await runJob(id); + toast.success("Job çalıştırılıyor"); + } catch { + toast.error("Job çalıştırılamadı"); } finally { - setDeletingId(null); + setRunningId(null); } }; @@ -186,10 +186,6 @@ export function JobsPage() {

Jobs

-

- Home sayfasında başlatılan sayaç:{" "} - {value} ({running ? "çalışıyor" : "beklemede"}) -

-
diff --git a/frontend/src/providers/live-provider.tsx b/frontend/src/providers/live-provider.tsx index d5aa5a1..1b78017 100644 --- a/frontend/src/providers/live-provider.tsx +++ b/frontend/src/providers/live-provider.tsx @@ -1,22 +1,16 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; import { useSocket } from "./socket-provider"; -type LiveState = { - value: number; - running: boolean; -}; - type JobStream = { logs: string[]; status?: string; lastRunAt?: string; lastMessage?: string; runCount?: number; + lastDurationMs?: number; }; -type LiveContextValue = LiveState & { - startCounter: () => void; - stopCounter: () => void; +type LiveContextValue = { jobStreams: Record; }; @@ -24,22 +18,11 @@ 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; - const handleUpdate = (payload: { value: number }) => { - setState({ value: payload.value, running: true }); - }; - - const handleStopped = (payload: { value: number }) => { - setState({ value: payload.value, running: false }); - }; - - socket.on("counter:update", handleUpdate); - socket.on("counter:stopped", handleStopped); const handleJobLog = ({ jobId, line }: { jobId: string; line: string }) => { if (!jobId) return; setJobStreams((prev) => { @@ -54,67 +37,45 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children status, lastRunAt, lastMessage, - runCount + runCount, + lastDurationMs }: { jobId: string; status?: string; lastRunAt?: string; lastMessage?: string; runCount?: number; + lastDurationMs?: number; }) => { if (!jobId) return; setJobStreams((prev) => { const current = prev[jobId] || { logs: [] }; - return { ...prev, [jobId]: { ...current, status, lastRunAt, lastMessage, runCount } }; + return { ...prev, [jobId]: { ...current, status, lastRunAt, lastMessage, runCount, lastDurationMs } }; }); }; - 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]); - const startCounter = useMemo( - () => () => { - socket?.emit("counter:start"); - }, - [socket] - ); - - const stopCounter = useMemo( - () => () => { - socket?.emit("counter:stop"); - }, - [socket] - ); - const value = useMemo( () => ({ - value: state.value, - running: state.running, - startCounter, - stopCounter, jobStreams }), - [state, startCounter, stopCounter, jobStreams] + [jobStreams] ); return {children}; }; -export function useLiveCounter() { +export function useLiveData() { const ctx = useContext(LiveContext); - if (!ctx) throw new Error("useLiveCounter LiveProvider içinde kullanılmalı"); + if (!ctx) throw new Error("useLiveData LiveProvider içinde kullanılmalı"); return ctx; }