Detail and List card update
This commit is contained in:
@@ -10,7 +10,19 @@ router.use(authMiddleware);
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
const jobs = await Job.find().sort({ createdAt: -1 }).lean();
|
||||
res.json(jobs);
|
||||
const counts = await JobRun.aggregate([
|
||||
{ $group: { _id: "$job", runCount: { $sum: 1 } } }
|
||||
]);
|
||||
const countMap = counts.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item._id.toString()] = item.runCount;
|
||||
return acc;
|
||||
}, {});
|
||||
res.json(
|
||||
jobs.map((job) => ({
|
||||
...job,
|
||||
runCount: countMap[job._id.toString()] || 0
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
router.get("/:id", async (req, res) => {
|
||||
|
||||
@@ -92,14 +92,16 @@ class JobService {
|
||||
this.io = io;
|
||||
}
|
||||
|
||||
private emitStatus(jobId: string, payload: Partial<JobDocument>) {
|
||||
private async emitStatus(jobId: string, payload: Partial<JobDocument>) {
|
||||
if (!this.io) return;
|
||||
const runCount = await JobRun.countDocuments({ job: jobId });
|
||||
const body = {
|
||||
jobId,
|
||||
status: payload.status,
|
||||
lastRunAt: payload.lastRunAt,
|
||||
lastDurationMs: payload.lastDurationMs,
|
||||
lastMessage: payload.lastMessage
|
||||
lastMessage: payload.lastMessage,
|
||||
runCount
|
||||
};
|
||||
this.io.to(`job:${jobId}`).emit("job:status", body);
|
||||
this.io.emit("job:status", body);
|
||||
@@ -130,7 +132,7 @@ class JobService {
|
||||
});
|
||||
|
||||
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);
|
||||
await this.emitStatus(jobId, { status: "running", lastMessage: "Çalıştırılıyor..." } as JobDocument);
|
||||
|
||||
try {
|
||||
const repoDir = await cloneOrPull(job, (line) => pushLog(line));
|
||||
@@ -151,7 +153,7 @@ class JobService {
|
||||
durationMs: duration,
|
||||
logs: runLogs
|
||||
});
|
||||
this.emitStatus(jobId, {
|
||||
await this.emitStatus(jobId, {
|
||||
status: "success",
|
||||
lastRunAt: new Date(),
|
||||
lastDurationMs: duration,
|
||||
@@ -172,7 +174,7 @@ class JobService {
|
||||
logs: runLogs
|
||||
});
|
||||
pushLog(`Hata: ${(err as Error).message}`);
|
||||
this.emitStatus(jobId, {
|
||||
await this.emitStatus(jobId, {
|
||||
status: "failed",
|
||||
lastRunAt: new Date(),
|
||||
lastDurationMs: duration,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface Job {
|
||||
lastRunAt?: string;
|
||||
lastDurationMs?: number;
|
||||
lastMessage?: string;
|
||||
runCount?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import gitlabLogo from "../assets/gitlab.png";
|
||||
|
||||
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")) 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" />;
|
||||
return <FontAwesomeIcon icon={faCodeBranch} className="h-5 w-5 text-foreground" />;
|
||||
if (lower.includes("github.com")) return <FontAwesomeIcon icon={faGithub} className="h-6 w-6 text-foreground" />;
|
||||
if (lower.includes("gitlab")) return <img src={gitlabLogo} alt="GitLab" className="h-6 w-6 object-contain" />;
|
||||
if (lower.includes("gitea")) return <img src={giteaLogo} alt="Gitea" className="h-6 w-6 object-contain" />;
|
||||
return <FontAwesomeIcon icon={faCodeBranch} className="h-6 w-6 text-foreground" />;
|
||||
}
|
||||
|
||||
@@ -5,16 +5,30 @@ import {
|
||||
faArrowLeft,
|
||||
faCircleCheck,
|
||||
faCircleExclamation,
|
||||
faClock
|
||||
faClock,
|
||||
faPen,
|
||||
faTrash
|
||||
} 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, JobRun, runJob } from "../api/jobs";
|
||||
import { deleteJob, fetchJob, Job, JobInput, JobRun, runJob, updateJob } from "../api/jobs";
|
||||
import { useJobStream } from "../providers/live-provider";
|
||||
import { useSocket } from "../providers/socket-provider";
|
||||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||
import { faListCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
repoUrl: string;
|
||||
testCommand: string;
|
||||
checkValue: string;
|
||||
checkUnit: JobInput["checkUnit"];
|
||||
};
|
||||
|
||||
export function JobDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -26,12 +40,27 @@ export function JobDetailPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [triggering, setTriggering] = useState(false);
|
||||
const [countdown, setCountdown] = useState<string>("00:00:00");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<FormState>({
|
||||
name: "",
|
||||
repoUrl: "",
|
||||
testCommand: "",
|
||||
checkValue: "",
|
||||
checkUnit: "dakika"
|
||||
});
|
||||
const stream = useJobStream(id || "");
|
||||
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(() => {
|
||||
if (stream.runCount !== undefined) {
|
||||
setRunCount(stream.runCount);
|
||||
}
|
||||
}, [stream.runCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
fetchJob(id)
|
||||
@@ -39,6 +68,13 @@ export function JobDetailPage() {
|
||||
setJob(data.job);
|
||||
setLastRun(data.lastRun || null);
|
||||
setRunCount(data.runCount || 0);
|
||||
setForm({
|
||||
name: data.job.name,
|
||||
repoUrl: data.job.repoUrl,
|
||||
testCommand: data.job.testCommand,
|
||||
checkValue: String(data.job.checkValue),
|
||||
checkUnit: data.job.checkUnit
|
||||
});
|
||||
})
|
||||
.catch(() => setError("Job bulunamadı"))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -114,11 +150,74 @@ export function JobDetailPage() {
|
||||
setTriggering(true);
|
||||
try {
|
||||
await runJob(id);
|
||||
setRunCount((c) => c + 1);
|
||||
} finally {
|
||||
setTriggering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!job) return;
|
||||
setForm({
|
||||
name: job.name,
|
||||
repoUrl: job.repoUrl,
|
||||
testCommand: job.testCommand,
|
||||
checkValue: String(job.checkValue),
|
||||
checkUnit: job.checkUnit
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!job?._id) return;
|
||||
const ok = window.confirm("Bu job'ı silmek istediğinize emin misiniz?");
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteJob(job._id);
|
||||
toast.success("Job silindi");
|
||||
navigate("/jobs", { replace: true });
|
||||
} catch (err) {
|
||||
toast.error("Job silinemedi");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!job?._id) return;
|
||||
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;
|
||||
}
|
||||
await updateJob(job._id, payload);
|
||||
toast.success("Job güncellendi");
|
||||
setJob((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
...payload
|
||||
}
|
||||
: prev
|
||||
);
|
||||
setEditOpen(false);
|
||||
await runJob(job._id);
|
||||
setRunCount((c) => c + 1);
|
||||
} catch {
|
||||
toast.error("Güncelleme başarısız");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [currentLogs]);
|
||||
@@ -164,15 +263,35 @@ export function JobDetailPage() {
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="h-4 w-4" />
|
||||
Geri
|
||||
</Button>
|
||||
<Button onClick={handleRun} disabled={triggering || !id} className="gap-2">
|
||||
{triggering ? "Çalıştırılıyor..." : "Run Test"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 transition hover:bg-emerald-100"
|
||||
onClick={handleEdit}
|
||||
disabled={!job}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPen} className="h-4 w-4 text-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 transition hover:bg-red-100"
|
||||
onClick={handleDelete}
|
||||
disabled={!job}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-foreground" />
|
||||
</Button>
|
||||
<Button onClick={handleRun} disabled={triggering || !id} className="gap-2">
|
||||
{triggering ? "Çalıştırılıyor..." : "Run Test"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-border card-shadow">
|
||||
<CardHeader className="flex flex-row items-start gap-3">
|
||||
{job && (
|
||||
<div className="pt-1">
|
||||
<div className="pt-2">
|
||||
<RepoIcon repoUrl={job.repoUrl} />
|
||||
</div>
|
||||
)}
|
||||
@@ -219,8 +338,92 @@ export function JobDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{editOpen && (
|
||||
<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">Job Güncelle</div>
|
||||
<div className="text-sm text-muted-foreground">Değişiklikler kaydedildiğinde test yeniden tetiklenecek.</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => setEditOpen(false)}>
|
||||
✕
|
||||
</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={() => setEditOpen(false)} disabled={saving}>
|
||||
İptal
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Kaydediliyor..." : "Kaydet ve Çalıştır"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="border-border card-shadow">
|
||||
<CardHeader>
|
||||
@@ -274,7 +477,7 @@ export function JobDetailPage() {
|
||||
|
||||
<Card className="border-border card-shadow">
|
||||
<CardHeader>
|
||||
<CardTitle>Canlı Çıktı</CardTitle>
|
||||
<CardTitle>Logs</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">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faListCheck, faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -18,6 +18,7 @@ import { createJob, deleteJob, fetchJobs, Job, JobInput, updateJob } from "../ap
|
||||
import { RepoIcon } from "../components/RepoIcon";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
type FormState = {
|
||||
_id?: string;
|
||||
@@ -39,12 +40,15 @@ const defaultForm: FormState = {
|
||||
export function JobsPage() {
|
||||
const { value, running, jobStreams } = useLiveCounter();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
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 [now, setNow] = useState(() => Date.now());
|
||||
const [pendingEditId, setPendingEditId] = useState<string | null>(null);
|
||||
|
||||
const isEdit = useMemo(() => !!form._id, [form._id]);
|
||||
|
||||
@@ -64,6 +68,46 @@ export function JobsPage() {
|
||||
loadJobs();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as { editJobId?: string } | null;
|
||||
if (state?.editJobId) {
|
||||
setPendingEditId(state.editJobId);
|
||||
navigate(location.pathname, { replace: true });
|
||||
}
|
||||
}, [location.state, navigate, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingEditId || jobs.length === 0) return;
|
||||
const job = jobs.find((j) => j._id === pendingEditId);
|
||||
if (job) {
|
||||
handleEdit(job);
|
||||
setPendingEditId(null);
|
||||
}
|
||||
}, [pendingEditId, jobs]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const formatCountdown = (job: Job, stream: ReturnType<typeof useLiveCounter>["jobStreams"][string]) => {
|
||||
const lastRunAt = stream?.lastRunAt || job.lastRunAt;
|
||||
if (!lastRunAt) return "00:00:00";
|
||||
const baseMs =
|
||||
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(lastRunAt).getTime() + baseMs;
|
||||
const diff = Math.max(target - now, 0);
|
||||
const totalSec = Math.floor(diff / 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 handleOpenNew = () => {
|
||||
setForm(defaultForm);
|
||||
setModalOpen(true);
|
||||
@@ -93,11 +137,15 @@ export function JobsPage() {
|
||||
|
||||
if (isEdit && form._id) {
|
||||
const updated = await updateJob(form._id, payload);
|
||||
setJobs((prev) => prev.map((j) => (j._id === updated._id ? updated : j)));
|
||||
setJobs((prev) =>
|
||||
prev.map((j) =>
|
||||
j._id === updated._id ? { ...updated, runCount: j.runCount ?? updated.runCount } : j
|
||||
)
|
||||
);
|
||||
toast.success("Job güncellendi");
|
||||
} else {
|
||||
const created = await createJob(payload);
|
||||
setJobs((prev) => [created, ...prev]);
|
||||
setJobs((prev) => [{ ...created, runCount: created.runCount ?? 0 }, ...prev]);
|
||||
toast.success("Job oluşturuldu");
|
||||
}
|
||||
setModalOpen(false);
|
||||
@@ -164,6 +212,8 @@ export function JobsPage() {
|
||||
jobs.map((job) => {
|
||||
const stream = jobStreams[job._id];
|
||||
const status = stream?.status || job.status || "idle";
|
||||
const runCount = stream?.runCount ?? job.runCount ?? 0;
|
||||
const countdown = formatCountdown(job, stream);
|
||||
return (
|
||||
<Card
|
||||
key={job._id}
|
||||
@@ -176,9 +226,13 @@ export function JobsPage() {
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-lg font-semibold text-foreground">{job.name}</div>
|
||||
<JobStatusBadge status={status} />
|
||||
<span className="ml-1 inline-flex items-center gap-1 rounded-full border border-border bg-muted/60 px-2 py-1 text-[11px] font-semibold text-foreground">
|
||||
<FontAwesomeIcon icon={faListCheck} className="h-3.5 w-3.5 text-foreground/80" />
|
||||
{runCount}x
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -220,6 +274,10 @@ export function JobsPage() {
|
||||
<span className="text-foreground/80">
|
||||
{job.checkValue} {job.checkUnit}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ type JobStream = {
|
||||
status?: string;
|
||||
lastRunAt?: string;
|
||||
lastMessage?: string;
|
||||
runCount?: number;
|
||||
};
|
||||
|
||||
type LiveContextValue = LiveState & {
|
||||
@@ -52,17 +53,19 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
jobId,
|
||||
status,
|
||||
lastRunAt,
|
||||
lastMessage
|
||||
lastMessage,
|
||||
runCount
|
||||
}: {
|
||||
jobId: string;
|
||||
status?: string;
|
||||
lastRunAt?: string;
|
||||
lastMessage?: string;
|
||||
runCount?: number;
|
||||
}) => {
|
||||
if (!jobId) return;
|
||||
setJobStreams((prev) => {
|
||||
const current = prev[jobId] || { logs: [] };
|
||||
return { ...prev, [jobId]: { ...current, status, lastRunAt, lastMessage } };
|
||||
return { ...prev, [jobId]: { ...current, status, lastRunAt, lastMessage, runCount } };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -118,5 +121,8 @@ export function useLiveCounter() {
|
||||
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]);
|
||||
return useMemo(
|
||||
() => ctx.jobStreams[jobId] || { logs: [], status: "idle", runCount: 0 },
|
||||
[ctx.jobStreams, jobId]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user