316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
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 <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() {
|
||
const { value, running } = useLiveCounter();
|
||
const [jobs, setJobs] = useState<Job[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [form, setForm] = useState<FormState>(defaultForm);
|
||
const [saving, setSaving] = useState(false);
|
||
const [deletingId, setDeletingId] = useState<string | null>(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 (
|
||
<>
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-xl font-semibold text-foreground">Jobs</h2>
|
||
<p className="text-sm text-muted-foreground">
|
||
Home sayfasında başlatılan sayaç:{" "}
|
||
<span className="font-mono text-foreground">{value}</span> ({running ? "çalışıyor" : "beklemede"})
|
||
</p>
|
||
</div>
|
||
<Button onClick={handleOpenNew} className="gap-2">
|
||
<FontAwesomeIcon icon={faPlus} className="h-4 w-4" />
|
||
New Job
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid gap-4">
|
||
{loading && (
|
||
<div className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||
Jobs yükleniyor...
|
||
</div>
|
||
)}
|
||
{!loading && jobs.length === 0 && (
|
||
<div className="rounded-md border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
||
Henüz job yok. Sağ üstten ekleyebilirsiniz.
|
||
</div>
|
||
)}
|
||
{!loading &&
|
||
jobs.map((job) => (
|
||
<Card key={job._id} className="border-border card-shadow">
|
||
<CardContent className="flex items-start gap-4 px-4 py-4">
|
||
<div className="pt-2.5">
|
||
<RepoIcon repoUrl={job.repoUrl} />
|
||
</div>
|
||
<div className="flex-1 space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-lg font-semibold text-foreground">{job.name}</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="h-10 w-10"
|
||
onClick={() => handleEdit(job)}
|
||
>
|
||
<FontAwesomeIcon icon={faPen} className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="h-10 w-10"
|
||
disabled={deletingId === job._id}
|
||
onClick={() => handleDelete(job._id)}
|
||
>
|
||
<FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-destructive" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-1 text-sm text-muted-foreground">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-foreground">Repo:</span>
|
||
<span className="truncate text-foreground/80">{job.repoUrl}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-foreground">Test:</span>
|
||
<span className="text-foreground/80">{job.testCommand}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-foreground">Kontrol:</span>
|
||
<span className="text-foreground/80">
|
||
{job.checkValue} {job.checkUnit}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{modalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
|
||
<div className="w-full max-w-lg rounded-lg border border-border bg-card card-shadow">
|
||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||
<div className="space-y-1">
|
||
<div className="text-lg font-semibold text-foreground">{isEdit ? "Job Güncelle" : "Yeni Job"}</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
Detayları girin, Jobs listesi canlı olarak güncellenecek.
|
||
</div>
|
||
</div>
|
||
<Button variant="ghost" size="icon" onClick={handleClose}>
|
||
✕
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-4 px-5 py-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="name">Job Name</Label>
|
||
<Input
|
||
id="name"
|
||
value={form.name}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||
placeholder="Nightly E2E"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="repo">Repo URL</Label>
|
||
<Input
|
||
id="repo"
|
||
value={form.repoUrl}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
|
||
placeholder="https://github.com/org/repo"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="test">Test Command</Label>
|
||
<Input
|
||
id="test"
|
||
value={form.testCommand}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, testCommand: e.target.value }))}
|
||
placeholder="npm test"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Check Time</Label>
|
||
<div className="flex gap-3">
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
className="w-32"
|
||
value={form.checkValue}
|
||
placeholder="15"
|
||
onChange={(e) => setForm((prev) => ({ ...prev, checkValue: e.target.value }))}
|
||
/>
|
||
<Select
|
||
value={form.checkUnit}
|
||
onValueChange={(value) =>
|
||
setForm((prev) => ({ ...prev, checkUnit: value as JobInput["checkUnit"] }))
|
||
}
|
||
>
|
||
<SelectTrigger className="flex-1">
|
||
<SelectValue placeholder="Birim seçin" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="dakika">dakika</SelectItem>
|
||
<SelectItem value="saat">saat</SelectItem>
|
||
<SelectItem value="gün">gün</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
|
||
<Button variant="ghost" onClick={handleClose} disabled={saving}>
|
||
İptal
|
||
</Button>
|
||
<Button onClick={handleSave} disabled={saving}>
|
||
{saving ? "Kaydediliyor..." : "Kaydet"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|