Updates
This commit is contained in:
29
backend/src/models/jobRun.ts
Normal file
29
backend/src/models/jobRun.ts
Normal file
@@ -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<JobRunDocument>(
|
||||||
|
{
|
||||||
|
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<JobRunDocument>("JobRun", JobRunSchema);
|
||||||
@@ -2,6 +2,7 @@ import { Router } from "express";
|
|||||||
import { authMiddleware } from "../middleware/authMiddleware.js";
|
import { authMiddleware } from "../middleware/authMiddleware.js";
|
||||||
import { Job } from "../models/job.js";
|
import { Job } from "../models/job.js";
|
||||||
import { jobService } from "../services/jobService.js";
|
import { jobService } from "../services/jobService.js";
|
||||||
|
import { JobRun } from "../models/jobRun.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -16,7 +17,9 @@ 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();
|
||||||
if (!job) return res.status(404).json({ message: "Job bulunamadı" });
|
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) => {
|
router.post("/", async (req, res) => {
|
||||||
@@ -27,6 +30,8 @@ router.post("/", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit });
|
const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit });
|
||||||
jobService.scheduleJob(job);
|
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);
|
return res.status(201).json(job);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(400).json({ message: "Job oluşturulamadı", error: (err as Error).message });
|
return res.status(400).json({ message: "Job oluşturulamadı", error: (err as Error).message });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from "path";
|
|||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { Server } from "socket.io";
|
import { Server } from "socket.io";
|
||||||
import { Job, JobDocument, TimeUnit } from "../models/job.js";
|
import { Job, JobDocument, TimeUnit } from "../models/job.js";
|
||||||
|
import { JobRun } from "../models/jobRun.js";
|
||||||
|
|
||||||
const repoBaseDir = path.join(process.cwd(), "test-runs");
|
const repoBaseDir = path.join(process.cwd(), "test-runs");
|
||||||
|
|
||||||
@@ -107,6 +108,8 @@ class JobService {
|
|||||||
private emitLog(jobId: string, line: string) {
|
private emitLog(jobId: string, line: string) {
|
||||||
if (!this.io) return;
|
if (!this.io) return;
|
||||||
this.io.to(`job:${jobId}`).emit("job:log", { jobId, line });
|
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) {
|
async runJob(jobId: string) {
|
||||||
@@ -114,15 +117,27 @@ class JobService {
|
|||||||
if (!job) return;
|
if (!job) return;
|
||||||
|
|
||||||
const startedAt = Date.now();
|
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..." });
|
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);
|
this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const repoDir = await cloneOrPull(job, (line) => this.emitLog(jobId, line));
|
const repoDir = await cloneOrPull(job, (line) => pushLog(line));
|
||||||
await ensureDependencies(repoDir, (line) => this.emitLog(jobId, line));
|
await ensureDependencies(repoDir, (line) => pushLog(line));
|
||||||
this.emitLog(jobId, `Test komutu çalıştırılıyor: ${job.testCommand}`);
|
pushLog(`Test komutu çalıştırılıyor: ${job.testCommand}`);
|
||||||
await runCommand(job.testCommand, repoDir, (line) => this.emitLog(jobId, line));
|
await runCommand(job.testCommand, repoDir, (line) => pushLog(line));
|
||||||
this.emitLog(jobId, "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, {
|
||||||
status: "success",
|
status: "success",
|
||||||
@@ -130,6 +145,12 @@ class JobService {
|
|||||||
lastDurationMs: duration,
|
lastDurationMs: duration,
|
||||||
lastMessage: "Başarılı"
|
lastMessage: "Başarılı"
|
||||||
});
|
});
|
||||||
|
await JobRun.findByIdAndUpdate(runDoc._id, {
|
||||||
|
status: "success",
|
||||||
|
finishedAt: new Date(),
|
||||||
|
durationMs: duration,
|
||||||
|
logs: runLogs
|
||||||
|
});
|
||||||
this.emitStatus(jobId, {
|
this.emitStatus(jobId, {
|
||||||
status: "success",
|
status: "success",
|
||||||
lastRunAt: new Date(),
|
lastRunAt: new Date(),
|
||||||
@@ -144,7 +165,13 @@ class JobService {
|
|||||||
lastDurationMs: duration,
|
lastDurationMs: duration,
|
||||||
lastMessage: (err as Error).message
|
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, {
|
this.emitStatus(jobId, {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
lastRunAt: new Date(),
|
lastRunAt: new Date(),
|
||||||
|
|||||||
1
backend/test-runs/6927ea6d4eeb13c399f5384e
Submodule
1
backend/test-runs/6927ea6d4eeb13c399f5384e
Submodule
Submodule backend/test-runs/6927ea6d4eeb13c399f5384e added at 4167b506aa
1
backend/test-runs/6927f441d032c5edf4ce4417
Submodule
1
backend/test-runs/6927f441d032c5edf4ce4417
Submodule
Submodule backend/test-runs/6927f441d032c5edf4ce4417 added at 4167b506aa
1
backend/test-runs/6927f4801d3a3242d50578c8
Submodule
1
backend/test-runs/6927f4801d3a3242d50578c8
Submodule
Submodule backend/test-runs/6927f4801d3a3242d50578c8 added at 4167b506aa
1
backend/test-runs/6927fa601d3a3242d5057904
Submodule
1
backend/test-runs/6927fa601d3a3242d5057904
Submodule
Submodule backend/test-runs/6927fa601d3a3242d5057904 added at 4167b506aa
1
backend/test-runs/69280287fc7de8c33147ec69
Submodule
1
backend/test-runs/69280287fc7de8c33147ec69
Submodule
Submodule backend/test-runs/69280287fc7de8c33147ec69 added at 035d049204
1
backend/test-runs/6928185ff4e571855c319cb9
Submodule
1
backend/test-runs/6928185ff4e571855c319cb9
Submodule
Submodule backend/test-runs/6928185ff4e571855c319cb9 added at 035d049204
1
backend/test-runs/69281978f4e571855c319cdb
Submodule
1
backend/test-runs/69281978f4e571855c319cdb
Submodule
Submodule backend/test-runs/69281978f4e571855c319cdb added at 4167b506aa
1
backend/test-runs/69281d3f034039681b7f35b6
Submodule
1
backend/test-runs/69281d3f034039681b7f35b6
Submodule
Submodule backend/test-runs/69281d3f034039681b7f35b6 added at 4167b506aa
@@ -17,6 +17,24 @@ export interface Job {
|
|||||||
updatedAt: string;
|
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 {
|
export interface JobInput {
|
||||||
name: string;
|
name: string;
|
||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
@@ -35,9 +53,9 @@ export async function createJob(payload: JobInput): Promise<Job> {
|
|||||||
return data as Job;
|
return data as Job;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchJob(id: string): Promise<Job> {
|
export async function fetchJob(id: string): Promise<JobDetailResponse> {
|
||||||
const { data } = await apiClient.get(`/jobs/${id}`);
|
const { data } = await apiClient.get(`/jobs/${id}`);
|
||||||
return data as Job;
|
return data as JobDetailResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateJob(id: string, payload: JobInput): Promise<Job> {
|
export async function updateJob(id: string, payload: JobInput): Promise<Job> {
|
||||||
|
|||||||
BIN
frontend/src/assets/gitea.png
Normal file
BIN
frontend/src/assets/gitea.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 329 KiB |
BIN
frontend/src/assets/gitlab.png
Normal file
BIN
frontend/src/assets/gitlab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 387 KiB |
46
frontend/src/components/JobStatusBadge.tsx
Normal file
46
frontend/src/components/JobStatusBadge.tsx
Normal file
@@ -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 (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold ${style.className}`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={style.icon} className={`h-3.5 w-3.5 ${style.iconClassName || ""}`} />
|
||||||
|
{style.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { 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 }) {
|
export function RepoIcon({ repoUrl }: { repoUrl: string }) {
|
||||||
const lower = repoUrl.toLowerCase();
|
const lower = repoUrl.toLowerCase();
|
||||||
if (lower.includes("github.com")) {
|
if (lower.includes("github.com")) return <FontAwesomeIcon icon={faGithub} className="h-5 w-5 text-foreground" />;
|
||||||
return <FontAwesomeIcon icon={faGithub} className="h-5 w-5 text-foreground" />;
|
if (lower.includes("gitlab")) return <img src={gitlabLogo} alt="GitLab" className="h-5 w-5 object-contain" />;
|
||||||
}
|
if (lower.includes("gitea")) return <img src={giteaLogo} alt="Gitea" className="h-5 w-5 object-contain" />;
|
||||||
if (lower.includes("gitlab.com")) {
|
|
||||||
return <FontAwesomeIcon icon={faGitlab} className="h-5 w-5 text-foreground" />;
|
|
||||||
}
|
|
||||||
if (lower.includes("gitea")) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-sm bg-emerald-600 text-[10px] font-semibold text-white">
|
|
||||||
Ge
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <FontAwesomeIcon icon={faCodeBranch} className="h-5 w-5 text-foreground" />;
|
return <FontAwesomeIcon icon={faCodeBranch} className="h-5 w-5 text-foreground" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useParams, useNavigate } from "react-router-dom";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { 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 { fetchJob, Job, runJob } from "../api/jobs";
|
import { fetchJob, Job, JobRun, runJob } 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";
|
||||||
const statusColor: Record<string, string> = {
|
import { faListCheck } from "@fortawesome/free-solid-svg-icons";
|
||||||
running: "text-amber-500",
|
|
||||||
success: "text-emerald-500",
|
|
||||||
finished: "text-emerald-500",
|
|
||||||
failed: "text-red-500",
|
|
||||||
idle: "text-muted-foreground"
|
|
||||||
};
|
|
||||||
|
|
||||||
export function JobDetailPage() {
|
export function JobDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [job, setJob] = useState<Job | null>(null);
|
const [job, setJob] = useState<Job | null>(null);
|
||||||
|
const [lastRun, setLastRun] = useState<JobRun | null>(null);
|
||||||
|
const [runCount, setRunCount] = useState<number>(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [triggering, setTriggering] = useState(false);
|
const [triggering, setTriggering] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState<string>("00:00:00");
|
||||||
const stream = useJobStream(id || "");
|
const stream = useJobStream(id || "");
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
|
const logEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const prevStatusRef = useRef<string | undefined>(undefined);
|
||||||
|
const currentLogs = stream.logs.length > 0 ? stream.logs : lastRun?.logs || [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
fetchJob(id)
|
fetchJob(id)
|
||||||
.then(setJob)
|
.then((data) => {
|
||||||
|
setJob(data.job);
|
||||||
|
setLastRun(data.lastRun || null);
|
||||||
|
setRunCount(data.runCount || 0);
|
||||||
|
})
|
||||||
.catch(() => setError("Job bulunamadı"))
|
.catch(() => setError("Job bulunamadı"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
@@ -43,11 +52,62 @@ export function JobDetailPage() {
|
|||||||
};
|
};
|
||||||
}, [socket, id]);
|
}, [socket, id]);
|
||||||
|
|
||||||
const statusText = useMemo(() => {
|
useEffect(() => {
|
||||||
const raw = stream.status || job?.status || "idle";
|
if (!stream.status) return;
|
||||||
if (raw === "success") return "finished";
|
const prev = prevStatusRef.current;
|
||||||
return raw;
|
prevStatusRef.current = stream.status;
|
||||||
}, [stream.status, job?.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 () => {
|
const handleRun = async () => {
|
||||||
if (!id) return;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -66,20 +164,6 @@ export function JobDetailPage() {
|
|||||||
<FontAwesomeIcon icon={faArrowLeft} className="h-4 w-4" />
|
<FontAwesomeIcon icon={faArrowLeft} className="h-4 w-4" />
|
||||||
Geri
|
Geri
|
||||||
</Button>
|
</Button>
|
||||||
{job && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>Durum:</span>
|
|
||||||
<span className={`flex items-center gap-1 font-semibold ${statusColor[statusText] || ""}`}>
|
|
||||||
<FontAwesomeIcon icon={faCircle} className="h-3 w-3" />
|
|
||||||
{statusText}
|
|
||||||
</span>
|
|
||||||
{job.lastRunAt && (
|
|
||||||
<span className="text-xs text-muted-foreground/80">
|
|
||||||
Son çalıştırma: {new Date(job.lastRunAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button onClick={handleRun} disabled={triggering || !id} className="gap-2">
|
<Button onClick={handleRun} disabled={triggering || !id} className="gap-2">
|
||||||
{triggering ? "Çalıştırılıyor..." : "Run Test"}
|
{triggering ? "Çalıştırılıyor..." : "Run Test"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -92,20 +176,19 @@ export function JobDetailPage() {
|
|||||||
<RepoIcon repoUrl={job.repoUrl} />
|
<RepoIcon repoUrl={job.repoUrl} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div className="flex w-full items-center gap-2">
|
||||||
<CardTitle>{job?.name || "Job Detayı"}</CardTitle>
|
<CardTitle>{job?.name || "Job Detayı"}</CardTitle>
|
||||||
<div className="text-sm text-muted-foreground">
|
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-muted/50 px-3 py-1 text-xs font-semibold text-foreground">
|
||||||
{job?.repoUrl}
|
<FontAwesomeIcon icon={faListCheck} className="h-3.5 w-3.5 text-foreground/80" />
|
||||||
{job?.testCommand ? ` · ${job.testCommand}` : ""}
|
{runCount} test
|
||||||
{job ? ` · ${job.checkValue} ${job.checkUnit}` : ""}
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{loading && <div className="text-sm text-muted-foreground">Yükleniyor...</div>}
|
{loading && <div className="text-sm text-muted-foreground">Yükleniyor...</div>}
|
||||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||||
{job && (
|
{job && (
|
||||||
<div className="grid gap-2 text-sm text-muted-foreground">
|
<div className="grid gap-3 text-sm text-muted-foreground">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<span className="font-medium text-foreground">Repo:</span>
|
<span className="font-medium text-foreground">Repo:</span>
|
||||||
<span className="truncate text-foreground/80">{job.repoUrl}</span>
|
<span className="truncate text-foreground/80">{job.repoUrl}</span>
|
||||||
@@ -114,31 +197,96 @@ export function JobDetailPage() {
|
|||||||
<span className="font-medium text-foreground">Test:</span>
|
<span className="font-medium text-foreground">Test:</span>
|
||||||
<span className="text-foreground/80">{job.testCommand}</span>
|
<span className="text-foreground/80">{job.testCommand}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-foreground">Kontrol:</span>
|
<span className="font-medium text-foreground">Kontrol:</span>
|
||||||
<span className="text-foreground/80">
|
<span className="text-foreground/80">
|
||||||
{job.checkValue} {job.checkUnit}
|
{job.checkValue} {job.checkUnit}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-lg leading-none text-muted-foreground">·</span>
|
||||||
|
<span className="rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground/80">
|
||||||
|
{countdown}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-foreground">Status:</span>
|
||||||
|
<JobStatusBadge status={effectiveStatus} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-foreground">Last Check:</span>
|
||||||
|
<span className="text-foreground/80">
|
||||||
|
{lastRunAt ? new Date(lastRunAt).toLocaleString() : "Henüz çalıştırılmadı"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border card-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Test Özeti</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{testSummary.testFiles ? (
|
||||||
|
<div className="flex items-start gap-3 text-sm text-foreground">
|
||||||
|
<FontAwesomeIcon icon={faCircleCheck} className="mt-0.5 h-4 w-4 text-emerald-600" />
|
||||||
|
<span className="font-medium">{testSummary.testFiles}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{testSummary.tests ? (
|
||||||
|
<div className="flex items-start gap-3 text-sm text-foreground">
|
||||||
|
<FontAwesomeIcon icon={faCircleCheck} className="mt-0.5 h-4 w-4 text-emerald-600" />
|
||||||
|
<span className="font-medium">{testSummary.tests}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{testSummary.passing ? (
|
||||||
|
<div className="flex items-start gap-3 text-sm text-foreground">
|
||||||
|
<FontAwesomeIcon icon={faCircleCheck} className="mt-0.5 h-4 w-4 text-emerald-600" />
|
||||||
|
<span className="font-medium">{testSummary.passing}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{testSummary.failing ? (
|
||||||
|
<div className="flex items-start gap-3 text-sm text-foreground">
|
||||||
|
<FontAwesomeIcon icon={faCircleExclamation} className="mt-0.5 h-4 w-4 text-red-500" />
|
||||||
|
<span className="font-medium">{testSummary.failing}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{testSummary.startAt ? (
|
||||||
|
<div className="flex items-start gap-3 text-sm text-foreground">
|
||||||
|
<FontAwesomeIcon icon={faClock} className="mt-0.5 h-4 w-4" style={{ color: "#F6C344" }} />
|
||||||
|
<span className="font-medium">{testSummary.startAt}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!testSummary.passing && !testSummary.failing && !testSummary.startAt && (
|
||||||
|
<div className="text-sm text-muted-foreground">Özet üretmek için henüz yeterli çıktı yok.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="border-border card-shadow">
|
<Card className="border-border card-shadow">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Canlı Çıktı</CardTitle>
|
<CardTitle>Canlı Çıktı</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-64 overflow-auto rounded-md border border-border bg-black p-3 font-mono text-xs text-green-100">
|
<div className="h-64 overflow-auto rounded-md border border-border bg-black p-3 font-mono text-xs text-green-100">
|
||||||
{stream.logs.length === 0 && (
|
{currentLogs.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>
|
||||||
)}
|
)}
|
||||||
{stream.logs.map((line, idx) => (
|
{currentLogs.map((line, idx) => (
|
||||||
<div key={idx} className="whitespace-pre-wrap">
|
<div key={idx} className="whitespace-pre-wrap">
|
||||||
{line}
|
{line}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div ref={logEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useLiveCounter } from "../providers/live-provider";
|
|||||||
import { createJob, deleteJob, fetchJobs, Job, JobInput, updateJob } from "../api/jobs";
|
import { createJob, deleteJob, fetchJobs, Job, JobInput, 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";
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
_id?: string;
|
_id?: string;
|
||||||
@@ -36,7 +37,7 @@ const defaultForm: FormState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function JobsPage() {
|
export function JobsPage() {
|
||||||
const { value, running } = useLiveCounter();
|
const { value, running, jobStreams } = useLiveCounter();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -108,6 +109,8 @@ export function JobsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
|
const ok = window.confirm("Bu job'ı silmek istediğinize emin misiniz?");
|
||||||
|
if (!ok) return;
|
||||||
setDeletingId(id);
|
setDeletingId(id);
|
||||||
try {
|
try {
|
||||||
await deleteJob(id);
|
await deleteJob(id);
|
||||||
@@ -158,7 +161,10 @@ export function JobsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loading &&
|
{!loading &&
|
||||||
jobs.map((job) => (
|
jobs.map((job) => {
|
||||||
|
const stream = jobStreams[job._id];
|
||||||
|
const status = stream?.status || job.status || "idle";
|
||||||
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={job._id}
|
key={job._id}
|
||||||
className="border-border card-shadow cursor-pointer"
|
className="border-border card-shadow cursor-pointer"
|
||||||
@@ -169,8 +175,11 @@ export function JobsPage() {
|
|||||||
<RepoIcon repoUrl={job.repoUrl} />
|
<RepoIcon repoUrl={job.repoUrl} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-lg font-semibold text-foreground">{job.name}</div>
|
<div className="text-lg font-semibold text-foreground">{job.name}</div>
|
||||||
|
<JobStatusBadge status={status} />
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -216,7 +225,8 @@ export function JobsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modalOpen && (
|
{modalOpen && (
|
||||||
|
|||||||
10
frontend/src/vite-env.d.ts
vendored
10
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,11 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.svg" {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.png" {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user