diff --git a/backend/src/index.ts b/backend/src/index.ts index c608b1c..f43242d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,6 +4,7 @@ import cors from "cors"; import mongoose from "mongoose"; import { Server } from "socket.io"; import authRoutes from "./routes/auth.js"; +import jobsRoutes from "./routes/jobs.js"; import { config } from "./config/env.js"; import jwt from "jsonwebtoken"; @@ -22,6 +23,7 @@ app.get("/health", (_req, res) => { }); app.use("/auth", authRoutes); +app.use("/jobs", jobsRoutes); const server = http.createServer(app); diff --git a/backend/src/models/job.ts b/backend/src/models/job.ts new file mode 100644 index 0000000..6460186 --- /dev/null +++ b/backend/src/models/job.ts @@ -0,0 +1,26 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export type TimeUnit = "dakika" | "saat" | "gün"; + +export interface JobDocument extends Document { + name: string; + repoUrl: string; + testCommand: string; + checkValue: number; + checkUnit: TimeUnit; + createdAt: Date; + updatedAt: Date; +} + +const JobSchema = new Schema( + { + name: { type: String, required: true, trim: true }, + repoUrl: { type: String, required: true, trim: true }, + testCommand: { type: String, required: true, trim: true }, + checkValue: { type: Number, required: true, min: 1 }, + checkUnit: { type: String, required: true, enum: ["dakika", "saat", "gün"] } + }, + { timestamps: true } +); + +export const Job = mongoose.model("Job", JobSchema); diff --git a/backend/src/routes/jobs.ts b/backend/src/routes/jobs.ts new file mode 100644 index 0000000..c1a0f93 --- /dev/null +++ b/backend/src/routes/jobs.ts @@ -0,0 +1,54 @@ +import { Router } from "express"; +import { authMiddleware } from "../middleware/authMiddleware.js"; +import { Job } from "../models/job.js"; + +const router = Router(); + +router.use(authMiddleware); + +router.get("/", async (_req, res) => { + const jobs = await Job.find().sort({ createdAt: -1 }).lean(); + res.json(jobs); +}); + +router.post("/", async (req, res) => { + const { name, repoUrl, testCommand, checkValue, checkUnit } = req.body; + if (!name || !repoUrl || !testCommand || !checkValue || !checkUnit) { + return res.status(400).json({ message: "Tüm alanlar gerekli" }); + } + try { + const job = await Job.create({ name, repoUrl, testCommand, checkValue, checkUnit }); + return res.status(201).json(job); + } catch (err) { + return res.status(400).json({ message: "Job oluşturulamadı", error: (err as Error).message }); + } +}); + +router.put("/:id", async (req, res) => { + const { id } = req.params; + const { name, repoUrl, testCommand, checkValue, checkUnit } = req.body; + try { + const job = await Job.findByIdAndUpdate( + id, + { name, repoUrl, testCommand, checkValue, checkUnit }, + { new: true, runValidators: true } + ); + if (!job) return res.status(404).json({ message: "Job bulunamadı" }); + return res.json(job); + } catch (err) { + return res.status(400).json({ message: "Job güncellenemedi", error: (err as Error).message }); + } +}); + +router.delete("/:id", async (req, res) => { + const { id } = req.params; + try { + const job = await Job.findByIdAndDelete(id); + if (!job) return res.status(404).json({ message: "Job bulunamadı" }); + return res.json({ success: true }); + } catch (err) { + return res.status(400).json({ message: "Job silinemedi", error: (err as Error).message }); + } +}); + +export default router; diff --git a/frontend/package.json b/frontend/package.json index 1ba3cbd..6426615 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,10 @@ }, "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.1.0", + "@fortawesome/free-brands-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.0", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.0.2", "axios": "^1.5.1", "class-variance-authority": "^0.7.0", diff --git a/frontend/src/api/jobs.ts b/frontend/src/api/jobs.ts new file mode 100644 index 0000000..2f49aea --- /dev/null +++ b/frontend/src/api/jobs.ts @@ -0,0 +1,41 @@ +import { apiClient } from "./client"; + +export type TimeUnit = "dakika" | "saat" | "gün"; + +export interface Job { + _id: string; + name: string; + repoUrl: string; + testCommand: string; + checkValue: number; + checkUnit: TimeUnit; + createdAt: string; + updatedAt: string; +} + +export interface JobInput { + name: string; + repoUrl: string; + testCommand: string; + checkValue: number; + checkUnit: TimeUnit; +} + +export async function fetchJobs(): Promise { + const { data } = await apiClient.get("/jobs"); + return data as Job[]; +} + +export async function createJob(payload: JobInput): Promise { + const { data } = await apiClient.post("/jobs", payload); + return data as Job; +} + +export async function updateJob(id: string, payload: JobInput): Promise { + const { data } = await apiClient.put(`/jobs/${id}`, payload); + return data as Job; +} + +export async function deleteJob(id: string): Promise { + await apiClient.delete(`/jobs/${id}`); +} diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..ee5325f --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; +import { cn } from "../../lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + + + {children} + + + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator +}; diff --git a/frontend/src/pages/JobsPage.tsx b/frontend/src/pages/JobsPage.tsx index d2cb728..f783c72 100644 --- a/frontend/src/pages/JobsPage.tsx +++ b/frontend/src/pages/JobsPage.tsx @@ -1,30 +1,315 @@ +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faGithub, faGitlab } from "@fortawesome/free-brands-svg-icons"; +import { faCodeBranch, faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { Label } from "../components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "../components/ui/select"; import { useLiveCounter } from "../providers/live-provider"; +import { createJob, deleteJob, fetchJobs, Job, JobInput, updateJob } from "../api/jobs"; + +type FormState = { + _id?: string; + name: string; + repoUrl: string; + testCommand: string; + checkValue: string; + checkUnit: JobInput["checkUnit"]; +}; + +const defaultForm: FormState = { + name: "", + repoUrl: "", + testCommand: "", + checkValue: "", + checkUnit: "dakika" +}; + +function RepoIcon({ repoUrl }: { repoUrl: string }) { + const lower = repoUrl.toLowerCase(); + if (lower.includes("github.com")) { + return ; + } + if (lower.includes("gitlab.com")) { + return ; + } + if (lower.includes("gitea")) { + return ( +
+ Ge +
+ ); + } + return ; +} export function JobsPage() { const { value, running } = useLiveCounter(); + 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 isEdit = useMemo(() => !!form._id, [form._id]); + + const loadJobs = async () => { + setLoading(true); + try { + const data = await fetchJobs(); + setJobs(data); + } catch (err) { + toast.error("Jobs alınamadı"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadJobs(); + }, []); + + const handleOpenNew = () => { + setForm(defaultForm); + setModalOpen(true); + }; + + const handleSave = async () => { + 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; + } + if (Number.isNaN(payload.checkValue) || payload.checkValue <= 0) { + toast.error("Check Time değeri 1 veya daha büyük olmalı"); + setSaving(false); + return; + } + + if (isEdit && form._id) { + const updated = await updateJob(form._id, payload); + setJobs((prev) => prev.map((j) => (j._id === updated._id ? updated : j))); + toast.success("Job güncellendi"); + } else { + const created = await createJob(payload); + setJobs((prev) => [created, ...prev]); + toast.success("Job oluşturuldu"); + } + setModalOpen(false); + } catch (err) { + toast.error("İşlem sırasında hata oluştu"); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + setDeletingId(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"); + } finally { + setDeletingId(null); + } + }; + + const handleEdit = (job: Job) => { + const { _id, name, repoUrl, testCommand, checkValue, checkUnit } = job; + setForm({ _id, name, repoUrl, testCommand, checkValue: String(checkValue), checkUnit }); + setModalOpen(true); + }; + + const handleClose = () => { + setModalOpen(false); + }; return ( - - - Jobs Durumu - Diğer sayfalarda başlatılan canlı sayaç burada izlenebilir. - - -
- Canlı Durum - - {running ? "Çalışıyor" : "Beklemede"} - + <> +
+
+

Jobs

+

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

-
- Sayaç Değeri - {value} + +
+ +
+ {loading && ( +
+ Jobs yükleniyor... +
+ )} + {!loading && jobs.length === 0 && ( +
+ Henüz job yok. Sağ üstten ekleyebilirsiniz. +
+ )} + {!loading && + jobs.map((job) => ( + + +
+ +
+
+
+
{job.name}
+
+ + +
+
+
+
+ Repo: + {job.repoUrl} +
+
+ Test: + {job.testCommand} +
+
+ Kontrol: + + {job.checkValue} {job.checkUnit} + +
+
+
+
+
+ ))} +
+ + {modalOpen && ( +
+
+
+
+
{isEdit ? "Job Güncelle" : "Yeni Job"}
+
+ Detayları girin, Jobs listesi canlı olarak güncellenecek. +
+
+ +
+
+
+ + 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 }))} + /> + +
+
+
+
+ + +
+
-
- Sayaç Home sayfasından başlatılır. Bu panel aynı anda açık olan tüm kullanıcılarda anlık güncellenir. -
- - + )} + ); }