Test modülü eklendi
This commit is contained in:
@@ -3,7 +3,7 @@ FROM node:20-alpine
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json .
|
COPY package*.json .
|
||||||
RUN npm install
|
RUN apk add --no-cache git openssh-client && npm install
|
||||||
|
|
||||||
COPY tsconfig.json .
|
COPY tsconfig.json .
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import authRoutes from "./routes/auth.js";
|
|||||||
import jobsRoutes from "./routes/jobs.js";
|
import jobsRoutes from "./routes/jobs.js";
|
||||||
import { config } from "./config/env.js";
|
import { config } from "./config/env.js";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
|
import { jobService } from "./services/jobService.js";
|
||||||
|
import { Job } from "./models/job.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ const io = new Server(server, {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jobService.setSocket(io);
|
||||||
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
let counterTimer: NodeJS.Timeout | null = null;
|
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) => {
|
socket.on("counter:status", (ack?: (payload: { running: boolean; value: number }) => void) => {
|
||||||
ack?.({ running: !!counterTimer, value: counter });
|
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() {
|
async function start() {
|
||||||
try {
|
try {
|
||||||
await mongoose.connect(config.mongoUri);
|
await mongoose.connect(config.mongoUri);
|
||||||
console.log("MongoDB'ye bağlanıldı");
|
console.log("MongoDB'ye bağlanıldı");
|
||||||
|
await jobService.bootstrap();
|
||||||
|
|
||||||
server.listen(config.port, () => {
|
server.listen(config.port, () => {
|
||||||
console.log(`Sunucu ${config.port} portunda çalışıyor`);
|
console.log(`Sunucu ${config.port} portunda çalışıyor`);
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export interface JobDocument extends Document {
|
|||||||
testCommand: string;
|
testCommand: string;
|
||||||
checkValue: number;
|
checkValue: number;
|
||||||
checkUnit: TimeUnit;
|
checkUnit: TimeUnit;
|
||||||
|
status: "idle" | "running" | "success" | "failed";
|
||||||
|
lastRunAt?: Date;
|
||||||
|
lastDurationMs?: number;
|
||||||
|
lastMessage?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -18,7 +22,11 @@ const JobSchema = new Schema<JobDocument>(
|
|||||||
repoUrl: { type: String, required: true, trim: true },
|
repoUrl: { type: String, required: true, trim: true },
|
||||||
testCommand: { type: String, required: true, trim: true },
|
testCommand: { type: String, required: true, trim: true },
|
||||||
checkValue: { type: Number, required: true, min: 1 },
|
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 }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from "express";
|
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";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -11,6 +12,13 @@ router.get("/", async (_req, res) => {
|
|||||||
res.json(jobs);
|
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) => {
|
router.post("/", async (req, res) => {
|
||||||
const { name, repoUrl, testCommand, checkValue, checkUnit } = req.body;
|
const { name, repoUrl, testCommand, checkValue, checkUnit } = req.body;
|
||||||
if (!name || !repoUrl || !testCommand || !checkValue || !checkUnit) {
|
if (!name || !repoUrl || !testCommand || !checkValue || !checkUnit) {
|
||||||
@@ -18,6 +26,7 @@ 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);
|
||||||
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 });
|
||||||
@@ -34,6 +43,7 @@ router.put("/:id", async (req, res) => {
|
|||||||
{ new: true, runValidators: true }
|
{ new: true, runValidators: true }
|
||||||
);
|
);
|
||||||
if (!job) return res.status(404).json({ message: "Job bulunamadı" });
|
if (!job) return res.status(404).json({ message: "Job bulunamadı" });
|
||||||
|
jobService.scheduleJob(job);
|
||||||
return res.json(job);
|
return res.json(job);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(400).json({ message: "Job güncellenemedi", error: (err as Error).message });
|
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 {
|
try {
|
||||||
const job = await Job.findByIdAndDelete(id);
|
const job = await Job.findByIdAndDelete(id);
|
||||||
if (!job) return res.status(404).json({ message: "Job bulunamadı" });
|
if (!job) return res.status(404).json({ message: "Job bulunamadı" });
|
||||||
|
jobService.clearJob(id);
|
||||||
return res.json({ success: true });
|
return res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(400).json({ message: "Job silinemedi", error: (err as Error).message });
|
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;
|
export default router;
|
||||||
|
|||||||
181
backend/src/services/jobService.ts
Normal file
181
backend/src/services/jobService.ts
Normal file
@@ -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<void>((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<string, NodeJS.Timeout> = new Map();
|
||||||
|
private io: Server | null = null;
|
||||||
|
|
||||||
|
setSocket(io: Server) {
|
||||||
|
this.io = io;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitStatus(jobId: string, payload: Partial<JobDocument>) {
|
||||||
|
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();
|
||||||
@@ -4,6 +4,7 @@ import { ProtectedRoute } from "./components/ProtectedRoute";
|
|||||||
import { DashboardLayout } from "./components/DashboardLayout";
|
import { DashboardLayout } from "./components/DashboardLayout";
|
||||||
import { HomePage } from "./pages/HomePage";
|
import { HomePage } from "./pages/HomePage";
|
||||||
import { JobsPage } from "./pages/JobsPage";
|
import { JobsPage } from "./pages/JobsPage";
|
||||||
|
import { JobDetailPage } from "./pages/JobDetailPage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -13,6 +14,7 @@ function App() {
|
|||||||
<Route element={<DashboardLayout />}>
|
<Route element={<DashboardLayout />}>
|
||||||
<Route path="/home" element={<HomePage />} />
|
<Route path="/home" element={<HomePage />} />
|
||||||
<Route path="/jobs" element={<JobsPage />} />
|
<Route path="/jobs" element={<JobsPage />} />
|
||||||
|
<Route path="/jobs/:id" element={<JobDetailPage />} />
|
||||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export interface Job {
|
|||||||
testCommand: string;
|
testCommand: string;
|
||||||
checkValue: number;
|
checkValue: number;
|
||||||
checkUnit: TimeUnit;
|
checkUnit: TimeUnit;
|
||||||
|
status?: "idle" | "running" | "success" | "failed";
|
||||||
|
lastRunAt?: string;
|
||||||
|
lastDurationMs?: number;
|
||||||
|
lastMessage?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -31,6 +35,11 @@ export async function createJob(payload: JobInput): Promise<Job> {
|
|||||||
return data as Job;
|
return data as Job;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchJob(id: string): Promise<Job> {
|
||||||
|
const { data } = await apiClient.get(`/jobs/${id}`);
|
||||||
|
return data as Job;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateJob(id: string, payload: JobInput): Promise<Job> {
|
export async function updateJob(id: string, payload: JobInput): Promise<Job> {
|
||||||
const { data } = await apiClient.put(`/jobs/${id}`, payload);
|
const { data } = await apiClient.put(`/jobs/${id}`, payload);
|
||||||
return data as Job;
|
return data as Job;
|
||||||
@@ -39,3 +48,7 @@ export async function updateJob(id: string, payload: JobInput): Promise<Job> {
|
|||||||
export async function deleteJob(id: string): Promise<void> {
|
export async function deleteJob(id: string): Promise<void> {
|
||||||
await apiClient.delete(`/jobs/${id}`);
|
await apiClient.delete(`/jobs/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runJob(id: string): Promise<void> {
|
||||||
|
await apiClient.post(`/jobs/${id}/run`);
|
||||||
|
}
|
||||||
|
|||||||
21
frontend/src/components/RepoIcon.tsx
Normal file
21
frontend/src/components/RepoIcon.tsx
Normal file
@@ -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 <FontAwesomeIcon icon={faGithub} className="h-5 w-5 text-foreground" />;
|
||||||
|
}
|
||||||
|
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" />;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Toaster as SonnerToaster } from "sonner";
|
|||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
return (
|
return (
|
||||||
<SonnerToaster
|
<SonnerToaster
|
||||||
position="top-right"
|
position="bottom-right"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
style: {
|
style: {
|
||||||
background: "hsl(var(--card))",
|
background: "hsl(var(--card))",
|
||||||
|
|||||||
147
frontend/src/pages/JobDetailPage.tsx
Normal file
147
frontend/src/pages/JobDetailPage.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { useEffect, useMemo, 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 { 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 { useJobStream } from "../providers/live-provider";
|
||||||
|
import { useSocket } from "../providers/socket-provider";
|
||||||
|
|
||||||
|
const statusColor: Record<string, string> = {
|
||||||
|
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<Job | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Button variant="ghost" className="gap-2" onClick={() => navigate(-1)}>
|
||||||
|
<FontAwesomeIcon icon={faArrowLeft} className="h-4 w-4" />
|
||||||
|
Geri
|
||||||
|
</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">
|
||||||
|
{triggering ? "Çalıştırılıyor..." : "Run Test"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-border card-shadow">
|
||||||
|
<CardHeader className="flex flex-row items-start gap-3">
|
||||||
|
{job && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<RepoIcon repoUrl={job.repoUrl} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<CardTitle>{job?.name || "Job Detayı"}</CardTitle>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{job?.repoUrl}
|
||||||
|
{job?.testCommand ? ` · ${job.testCommand}` : ""}
|
||||||
|
{job ? ` · ${job.checkValue} ${job.checkUnit}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{loading && <div className="text-sm text-muted-foreground">Yükleniyor...</div>}
|
||||||
|
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||||
|
{job && (
|
||||||
|
<div className="grid gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="font-medium text-foreground">Repo:</span>
|
||||||
|
<span className="truncate text-foreground/80">{job.repoUrl}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="font-medium text-foreground">Test:</span>
|
||||||
|
<span className="text-foreground/80">{job.testCommand}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="font-medium text-foreground">Kontrol:</span>
|
||||||
|
<span className="text-foreground/80">
|
||||||
|
{job.checkValue} {job.checkUnit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border card-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Canlı Çıktı</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<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 && (
|
||||||
|
<div className="text-muted-foreground">Henüz çıktı yok. Test çalıştırmaları bekleniyor.</div>
|
||||||
|
)}
|
||||||
|
{stream.logs.map((line, idx) => (
|
||||||
|
<div key={idx} className="whitespace-pre-wrap">
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faGithub, faGitlab } from "@fortawesome/free-brands-svg-icons";
|
import { faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { faCodeBranch, faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Label } from "../components/ui/label";
|
import { Label } from "../components/ui/label";
|
||||||
@@ -16,6 +15,8 @@ import {
|
|||||||
} from "../components/ui/select";
|
} from "../components/ui/select";
|
||||||
import { useLiveCounter } from "../providers/live-provider";
|
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 { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
_id?: string;
|
_id?: string;
|
||||||
@@ -34,26 +35,9 @@ const defaultForm: FormState = {
|
|||||||
checkUnit: "dakika"
|
checkUnit: "dakika"
|
||||||
};
|
};
|
||||||
|
|
||||||
function RepoIcon({ repoUrl }: { repoUrl: string }) {
|
|
||||||
const lower = repoUrl.toLowerCase();
|
|
||||||
if (lower.includes("github.com")) {
|
|
||||||
return <FontAwesomeIcon icon={faGithub} className="h-5 w-5 text-foreground" />;
|
|
||||||
}
|
|
||||||
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" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JobsPage() {
|
export function JobsPage() {
|
||||||
const { value, running } = useLiveCounter();
|
const { value, running } = useLiveCounter();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -175,7 +159,11 @@ export function JobsPage() {
|
|||||||
)}
|
)}
|
||||||
{!loading &&
|
{!loading &&
|
||||||
jobs.map((job) => (
|
jobs.map((job) => (
|
||||||
<Card key={job._id} className="border-border card-shadow">
|
<Card
|
||||||
|
key={job._id}
|
||||||
|
className="border-border card-shadow cursor-pointer"
|
||||||
|
onClick={() => navigate(`/jobs/${job._id}`)}
|
||||||
|
>
|
||||||
<CardContent className="flex items-start gap-4 px-4 py-4">
|
<CardContent className="flex items-start gap-4 px-4 py-4">
|
||||||
<div className="pt-2.5">
|
<div className="pt-2.5">
|
||||||
<RepoIcon repoUrl={job.repoUrl} />
|
<RepoIcon repoUrl={job.repoUrl} />
|
||||||
@@ -188,7 +176,10 @@ export function JobsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-10 w-10 transition hover:bg-emerald-100"
|
className="h-10 w-10 transition hover:bg-emerald-100"
|
||||||
onClick={() => handleEdit(job)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEdit(job);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPen} className="h-4 w-4 text-foreground" />
|
<FontAwesomeIcon icon={faPen} className="h-4 w-4 text-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -197,7 +188,10 @@ export function JobsPage() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-10 w-10 transition hover:bg-red-100"
|
className="h-10 w-10 transition hover:bg-red-100"
|
||||||
disabled={deletingId === job._id}
|
disabled={deletingId === job._id}
|
||||||
onClick={() => handleDelete(job._id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(job._id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-foreground" />
|
<FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,9 +6,17 @@ type LiveState = {
|
|||||||
running: boolean;
|
running: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type JobStream = {
|
||||||
|
logs: string[];
|
||||||
|
status?: string;
|
||||||
|
lastRunAt?: string;
|
||||||
|
lastMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type LiveContextValue = LiveState & {
|
type LiveContextValue = LiveState & {
|
||||||
startCounter: () => void;
|
startCounter: () => void;
|
||||||
stopCounter: () => void;
|
stopCounter: () => void;
|
||||||
|
jobStreams: Record<string, JobStream>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LiveContext = createContext<LiveContextValue | undefined>(undefined);
|
const LiveContext = createContext<LiveContextValue | undefined>(undefined);
|
||||||
@@ -16,6 +24,7 @@ const LiveContext = createContext<LiveContextValue | undefined>(undefined);
|
|||||||
export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const [state, setState] = useState<LiveState>({ value: 0, running: false });
|
const [state, setState] = useState<LiveState>({ value: 0, running: false });
|
||||||
|
const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
@@ -30,14 +39,45 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
|
|
||||||
socket.on("counter:update", handleUpdate);
|
socket.on("counter:update", handleUpdate);
|
||||||
socket.on("counter:stopped", handleStopped);
|
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 }) => {
|
socket.emit("counter:status", (payload: { value: number; running: boolean }) => {
|
||||||
setState({ value: payload.value, running: payload.running });
|
setState({ value: payload.value, running: payload.running });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on("job:log", handleJobLog);
|
||||||
|
socket.on("job:status", handleJobStatus);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("counter:update", handleUpdate);
|
socket.off("counter:update", handleUpdate);
|
||||||
socket.off("counter:stopped", handleStopped);
|
socket.off("counter:stopped", handleStopped);
|
||||||
|
socket.off("job:log", handleJobLog);
|
||||||
|
socket.off("job:status", handleJobStatus);
|
||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
@@ -60,9 +100,10 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
value: state.value,
|
value: state.value,
|
||||||
running: state.running,
|
running: state.running,
|
||||||
startCounter,
|
startCounter,
|
||||||
stopCounter
|
stopCounter,
|
||||||
|
jobStreams
|
||||||
}),
|
}),
|
||||||
[state, startCounter, stopCounter]
|
[state, startCounter, stopCounter, jobStreams]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
|
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
|
||||||
@@ -73,3 +114,9 @@ export function useLiveCounter() {
|
|||||||
if (!ctx) throw new Error("useLiveCounter LiveProvider içinde kullanılmalı");
|
if (!ctx) throw new Error("useLiveCounter LiveProvider içinde kullanılmalı");
|
||||||
return ctx;
|
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]);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user