Update
This commit is contained in:
@@ -38,29 +38,6 @@ const io = new Server(server, {
|
|||||||
|
|
||||||
jobService.setSocket(io);
|
jobService.setSocket(io);
|
||||||
|
|
||||||
let counter = 0;
|
|
||||||
let counterTimer: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
const broadcastCounter = () => {
|
|
||||||
io.emit("counter:update", { value: counter });
|
|
||||||
};
|
|
||||||
|
|
||||||
const startCounter = () => {
|
|
||||||
if (counterTimer) return;
|
|
||||||
counterTimer = setInterval(() => {
|
|
||||||
counter += 1;
|
|
||||||
broadcastCounter();
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopCounter = () => {
|
|
||||||
if (counterTimer) {
|
|
||||||
clearInterval(counterTimer);
|
|
||||||
counterTimer = null;
|
|
||||||
}
|
|
||||||
io.emit("counter:stopped", { value: counter });
|
|
||||||
};
|
|
||||||
|
|
||||||
io.use((socket, next) => {
|
io.use((socket, next) => {
|
||||||
const token = socket.handshake.auth?.token as string | undefined;
|
const token = socket.handshake.auth?.token as string | undefined;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -81,20 +58,6 @@ io.on("connection", (socket) => {
|
|||||||
socket.emit("pong", "pong");
|
socket.emit("pong", "pong");
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("counter:start", (ack?: (payload: { running: boolean; value: number }) => void) => {
|
|
||||||
startCounter();
|
|
||||||
ack?.({ running: true, value: counter });
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("counter:stop", (ack?: (payload: { running: boolean; value: number }) => void) => {
|
|
||||||
stopCounter();
|
|
||||||
ack?.({ running: false, value: counter });
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("counter:status", (ack?: (payload: { running: boolean; value: number }) => void) => {
|
|
||||||
ack?.({ running: !!counterTimer, value: counter });
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("job:subscribe", async ({ jobId }: { jobId: string }) => {
|
socket.on("job:subscribe", async ({ jobId }: { jobId: string }) => {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
socket.join(`job:${jobId}`);
|
socket.join(`job:${jobId}`);
|
||||||
|
|||||||
@@ -25,6 +25,52 @@ router.get("/", async (_req, res) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/metrics/summary", async (_req, res) => {
|
||||||
|
const since = new Date();
|
||||||
|
since.setDate(since.getDate() - 7);
|
||||||
|
|
||||||
|
const dailyStats = await JobRun.aggregate([
|
||||||
|
{ $match: { startedAt: { $gte: since } } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: { $dateToString: { format: "%Y-%m-%d", date: "$startedAt" } },
|
||||||
|
total: { $sum: 1 },
|
||||||
|
success: {
|
||||||
|
$sum: {
|
||||||
|
$cond: [{ $eq: ["$status", "success"] }, 1, 0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
$sum: {
|
||||||
|
$cond: [{ $eq: ["$status", "failed"] }, 1, 0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
avgDurationMs: { $avg: "$durationMs" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ $sort: { _id: 1 } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const recentRuns = await JobRun.find()
|
||||||
|
.sort({ startedAt: -1 })
|
||||||
|
.limit(10)
|
||||||
|
.populate("job", "name repoUrl status")
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
const successTotal = dailyStats.reduce((acc, d) => acc + (d.success || 0), 0);
|
||||||
|
const totalRuns = dailyStats.reduce((acc, d) => acc + (d.total || 0), 0);
|
||||||
|
const successRate = totalRuns ? Math.round((successTotal / totalRuns) * 100) : 0;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
dailyStats,
|
||||||
|
recentRuns,
|
||||||
|
totals: {
|
||||||
|
successRate,
|
||||||
|
totalRuns
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/:id", async (req, res) => {
|
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();
|
||||||
|
|||||||
@@ -34,8 +34,15 @@ function runCommand(command: string, cwd: string, onData: (chunk: string) => voi
|
|||||||
env: { ...process.env, CI: process.env.CI || "1" }
|
env: { ...process.env, CI: process.env.CI || "1" }
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stdout.on("data", (data) => onData(cleanOutput(data.toString())));
|
const emitLines = (chunk: Buffer) => {
|
||||||
child.stderr.on("data", (data) => onData(cleanOutput(data.toString())));
|
const cleaned = cleanOutput(chunk.toString()).replace(/\r\n|\r/g, "\n");
|
||||||
|
cleaned.split("\n").forEach((line) => {
|
||||||
|
onData(line);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout.on("data", emitLines);
|
||||||
|
child.stderr.on("data", emitLines);
|
||||||
|
|
||||||
child.on("error", (err) => {
|
child.on("error", (err) => {
|
||||||
onData(`Hata: ${err.message}`);
|
onData(`Hata: ${err.message}`);
|
||||||
|
|||||||
@@ -352,17 +352,12 @@ Job'ı manuel olarak çalıştırır.
|
|||||||
|
|
||||||
#### client → server
|
#### client → server
|
||||||
- `ping` - Bağlantı kontrolü
|
- `ping` - Bağlantı kontrolü
|
||||||
- `counter:start` - Sayaç başlatma
|
|
||||||
- `counter:stop` - Sayaç durdurma
|
|
||||||
- `counter:status` - Sayaç durumu sorgula
|
|
||||||
- `job:subscribe` - Job durum takibi
|
- `job:subscribe` - Job durum takibi
|
||||||
- `job:unsubscribe` - Job durum takibi bırakma
|
- `job:unsubscribe` - Job durum takibi bırakma
|
||||||
|
|
||||||
#### server → client
|
#### server → client
|
||||||
- `hello` - Bağlantı kurulduğunda
|
- `hello` - Bağlantı kurulduğunda
|
||||||
- `pong` - Ping yanıtı
|
- `pong` - Ping yanıtı
|
||||||
- `counter:update` - Sayaç değeri güncellemesi
|
|
||||||
- `counter:stopped` - Sayaç durduğunda
|
|
||||||
- `job:status` - Job durumu güncellemesi
|
- `job:status` - Job durumu güncellemesi
|
||||||
- `job:log` - Job log mesajı
|
- `job:log` - Job log mesajı
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.21.0",
|
"react-router-dom": "^6.21.0",
|
||||||
|
"recharts": "^3.5.0",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"sonner": "^1.4.0",
|
"sonner": "^1.4.0",
|
||||||
"tailwind-merge": "^1.14.0"
|
"tailwind-merge": "^1.14.0"
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export interface JobRun {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JobRunWithJob extends Omit<JobRun, "job"> {
|
||||||
|
job: Job;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobDetailResponse {
|
export interface JobDetailResponse {
|
||||||
job: Job;
|
job: Job;
|
||||||
lastRun?: JobRun;
|
lastRun?: JobRun;
|
||||||
@@ -71,3 +75,23 @@ export async function deleteJob(id: string): Promise<void> {
|
|||||||
export async function runJob(id: string): Promise<void> {
|
export async function runJob(id: string): Promise<void> {
|
||||||
await apiClient.post(`/jobs/${id}/run`);
|
await apiClient.post(`/jobs/${id}/run`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JobMetrics {
|
||||||
|
dailyStats: Array<{
|
||||||
|
_id: string;
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
avgDurationMs?: number;
|
||||||
|
}>;
|
||||||
|
recentRuns: JobRunWithJob[];
|
||||||
|
totals: {
|
||||||
|
successRate: number;
|
||||||
|
totalRuns: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchJobMetrics(): Promise<JobMetrics> {
|
||||||
|
const { data } = await apiClient.get("/jobs/metrics/summary");
|
||||||
|
return data as JobMetrics;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function DashboardLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<aside className="hidden w-64 flex-col border-r border-border bg-card/40 md:flex">
|
<aside className="fixed left-0 top-0 hidden h-screen w-64 flex-col border-r border-border bg-card/40 md:flex">
|
||||||
<div className="flex h-16 items-center border-b border-border px-6">
|
<div className="flex h-16 items-center border-b border-border px-6">
|
||||||
<span className="text-lg font-semibold tracking-tight">Wisecolt CI</span>
|
<span className="text-lg font-semibold tracking-tight">Wisecolt CI</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,6 +65,7 @@ export function DashboardLayout() {
|
|||||||
className="w-full justify-center gap-2"
|
className="w-full justify-center gap-2"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
disabled={isLoggingOut}
|
disabled={isLoggingOut}
|
||||||
|
title="Çıkış Yap"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faArrowRightFromBracket} className="h-4 w-4" />
|
<FontAwesomeIcon icon={faArrowRightFromBracket} className="h-4 w-4" />
|
||||||
Çıkış Yap
|
Çıkış Yap
|
||||||
@@ -72,7 +73,7 @@ export function DashboardLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex-1">
|
<main className="flex-1 overflow-y-auto md:ml-64">
|
||||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-8 sm:px-6 lg:px-8">
|
<div className="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -1,40 +1,212 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
CartesianGrid,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend
|
||||||
|
} from "recharts";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
import { Button } from "../components/ui/button";
|
import { useLiveData } from "../providers/live-provider";
|
||||||
import { useLiveCounter } from "../providers/live-provider";
|
import { fetchJobMetrics, JobMetrics } from "../api/jobs";
|
||||||
|
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||||
|
import { RepoIcon } from "../components/RepoIcon";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faClockRotateLeft, faListCheck } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
function formatDuration(ms?: number) {
|
||||||
|
if (!ms || Number.isNaN(ms)) return "-";
|
||||||
|
const seconds = Math.round(ms / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const rem = seconds % 60;
|
||||||
|
if (minutes < 60) return `${minutes}dk ${rem}s`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return `${hours}sa ${minutes % 60}dk`;
|
||||||
|
}
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { value, running, startCounter, stopCounter } = useLiveCounter();
|
const [metrics, setMetrics] = useState<JobMetrics | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { jobStreams } = useLiveData();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchJobMetrics()
|
||||||
|
.then(setMetrics)
|
||||||
|
.catch(() => setError("Metrikler alınamadı"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!metrics) return [];
|
||||||
|
return metrics.dailyStats.map((d) => ({
|
||||||
|
date: d._id,
|
||||||
|
Başarılı: d.success,
|
||||||
|
Hatalı: d.failed
|
||||||
|
}));
|
||||||
|
}, [metrics]);
|
||||||
|
|
||||||
|
const mergedRuns = useMemo(() => {
|
||||||
|
if (!metrics) return [];
|
||||||
|
return metrics.recentRuns.map((run, idx) => {
|
||||||
|
const live = jobStreams[run.job._id];
|
||||||
|
// Sadece en güncel run (liste başı) canlı güncellemeleri alır; geçmiş kayıtlar sabit kalır.
|
||||||
|
if (idx === 0 && live?.status) {
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
status: live.status,
|
||||||
|
finishedAt: live.lastRunAt || run.finishedAt,
|
||||||
|
durationMs: live.lastDurationMs ?? run.durationMs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return run;
|
||||||
|
});
|
||||||
|
}, [metrics, jobStreams]);
|
||||||
|
|
||||||
|
const lastRun = mergedRuns[0];
|
||||||
|
const lastRunDuration = formatDuration(lastRun?.durationMs);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-border card-shadow">
|
<div className="grid gap-6">
|
||||||
<CardHeader>
|
<div className="grid gap-4">
|
||||||
<CardTitle>Canlı Sayaç</CardTitle>
|
<Card className="border-border card-shadow">
|
||||||
<CardDescription>
|
<CardHeader className="space-y-1">
|
||||||
Sayaç sunucu tarafından çalışır; başlattıktan sonra diğer sayfalardan anlık izleyebilirsiniz.
|
<CardTitle>Son Çalıştırma</CardTitle>
|
||||||
</CardDescription>
|
<CardDescription>Son 10 çalıştırmanın en günceli</CardDescription>
|
||||||
</CardHeader>
|
<div className="text-xs text-muted-foreground">
|
||||||
<CardContent className="space-y-4">
|
Başarı oranı:{" "}
|
||||||
<div className="flex items-center justify-between rounded-md border border-border bg-muted/40 px-4 py-3">
|
<span className="font-semibold text-foreground">{metrics?.totals.successRate ?? 0}%</span>
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Durum</div>
|
|
||||||
<div className="text-lg font-semibold text-foreground">
|
|
||||||
{running ? "Çalışıyor" : "Beklemede"}
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading && <div className="text-sm text-muted-foreground">Yükleniyor...</div>}
|
||||||
|
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||||
|
{!loading && lastRun && (
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RepoIcon repoUrl={lastRun.job.repoUrl} />
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-semibold text-foreground">{lastRun.job.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{new Date(lastRun.startedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<JobStatusBadge status={lastRun.status} />
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Süre: <span className="font-semibold text-foreground">{lastRunDuration}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !lastRun && <div className="text-sm text-muted-foreground">Henüz kayıt yok.</div>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<Card className="border-border card-shadow lg:col-span-2">
|
||||||
|
<CardHeader className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Son 7 Gün Çalıştırma Trendleri</CardTitle>
|
||||||
|
<CardDescription>Başarılı / Hatalı job sayıları</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faClockRotateLeft} className="h-3.5 w-3.5" />
|
||||||
|
{metrics?.totals.totalRuns ?? 0} toplam koşu
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-80">
|
||||||
|
{chartData.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">Grafik verisi yok.</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis allowDecimals={false} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey="Başarılı" stroke="#10b981" strokeWidth={2} />
|
||||||
|
<Line type="monotone" dataKey="Hatalı" stroke="#ef4444" strokeWidth={2} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border card-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Hızlı Metrikler</CardTitle>
|
||||||
|
<CardDescription>Özet görünüm</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Başarı Oranı</span>
|
||||||
|
<span className="text-lg font-semibold text-foreground">
|
||||||
|
{metrics?.totals.successRate ?? 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Toplam Çalıştırma</span>
|
||||||
|
<span className="text-lg font-semibold text-foreground">
|
||||||
|
{metrics?.totals.totalRuns ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Son Süre</span>
|
||||||
|
<span className="text-lg font-semibold text-foreground">{lastRunDuration}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-border card-shadow">
|
||||||
|
<CardHeader className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Etkinlik Akışı</CardTitle>
|
||||||
|
<CardDescription>Son 10 job çalıştırması</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<div className="text-sm text-muted-foreground">Değer</div>
|
<FontAwesomeIcon icon={faListCheck} className="h-3.5 w-3.5" />
|
||||||
<div className="text-3xl font-bold text-foreground">{value}</div>
|
{mergedRuns.length ?? 0} kayıt
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
<div className="flex gap-3">
|
<CardContent className="space-y-3">
|
||||||
<Button className="flex-1" onClick={startCounter} disabled={running}>
|
{loading && <div className="text-sm text-muted-foreground">Yükleniyor...</div>}
|
||||||
Sayaçı Başlat
|
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||||
</Button>
|
{!loading && mergedRuns.length === 0 && (
|
||||||
<Button className="flex-1" variant="outline" onClick={stopCounter} disabled={!running}>
|
<div className="text-sm text-muted-foreground">Henüz çalıştırma yok.</div>
|
||||||
Durdur
|
)}
|
||||||
</Button>
|
{!loading &&
|
||||||
</div>
|
mergedRuns.map((run) => (
|
||||||
</CardContent>
|
<button
|
||||||
</Card>
|
key={run._id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/jobs/${run.job._id}`)}
|
||||||
|
className="flex w-full items-center justify-between rounded-md border border-border bg-muted/40 px-3 py-2 text-left transition hover:bg-muted"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RepoIcon repoUrl={run.job.repoUrl} />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-foreground">{run.job.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{new Date(run.startedAt).toLocaleString()} · Süre: {formatDuration(run.durationMs)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<JobStatusBadge status={run.status} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export function JobDetailPage() {
|
|||||||
const handleRun = async () => {
|
const handleRun = async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setTriggering(true);
|
setTriggering(true);
|
||||||
|
setCountdown("00:00:00");
|
||||||
try {
|
try {
|
||||||
await runJob(id);
|
await runJob(id);
|
||||||
setRunCount((c) => c + 1);
|
setRunCount((c) => c + 1);
|
||||||
@@ -223,6 +224,11 @@ export function JobDetailPage() {
|
|||||||
}, [currentLogs]);
|
}, [currentLogs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (effectiveStatus === "running") {
|
||||||
|
setCountdown("00:00:00");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const finishedAt = lastRun?.finishedAt || stream.lastRunAt || job?.lastRunAt;
|
const finishedAt = lastRun?.finishedAt || stream.lastRunAt || job?.lastRunAt;
|
||||||
if (!job || !job.checkUnit || !job.checkValue || !finishedAt) {
|
if (!job || !job.checkUnit || !job.checkValue || !finishedAt) {
|
||||||
setCountdown("00:00:00");
|
setCountdown("00:00:00");
|
||||||
@@ -270,6 +276,8 @@ export function JobDetailPage() {
|
|||||||
className="h-10 w-10 transition hover:bg-emerald-100"
|
className="h-10 w-10 transition hover:bg-emerald-100"
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
disabled={!job}
|
disabled={!job}
|
||||||
|
title="Job'ı düzenle"
|
||||||
|
aria-label="Job'ı düzenle"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPen} className="h-4 w-4 text-foreground" />
|
<FontAwesomeIcon icon={faPen} className="h-4 w-4 text-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -279,10 +287,18 @@ export function JobDetailPage() {
|
|||||||
className="h-10 w-10 transition hover:bg-red-100"
|
className="h-10 w-10 transition hover:bg-red-100"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={!job}
|
disabled={!job}
|
||||||
|
title="Job'ı sil"
|
||||||
|
aria-label="Job'ı sil"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-foreground" />
|
<FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleRun} disabled={triggering || !id} className="gap-2">
|
<Button
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={triggering || !id}
|
||||||
|
className="gap-2"
|
||||||
|
title="Testi çalıştır"
|
||||||
|
aria-label="Testi çalıştır"
|
||||||
|
>
|
||||||
{triggering ? "Çalıştırılıyor..." : "Run Test"}
|
{triggering ? "Çalıştırılıyor..." : "Run Test"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 { faListCheck, faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { faListCheck, faPen, faPlay, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Card, CardContent } 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";
|
||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "../components/ui/select";
|
} from "../components/ui/select";
|
||||||
import { useLiveCounter } from "../providers/live-provider";
|
import { useLiveData } from "../providers/live-provider";
|
||||||
import { createJob, deleteJob, fetchJobs, Job, JobInput, updateJob } from "../api/jobs";
|
import { createJob, fetchJobs, Job, JobInput, runJob, 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";
|
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||||||
@@ -38,7 +38,7 @@ const defaultForm: FormState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function JobsPage() {
|
export function JobsPage() {
|
||||||
const { value, running, jobStreams } = useLiveCounter();
|
const { jobStreams } = useLiveData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
@@ -46,9 +46,9 @@ export function JobsPage() {
|
|||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [form, setForm] = useState<FormState>(defaultForm);
|
const [form, setForm] = useState<FormState>(defaultForm);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const [pendingEditId, setPendingEditId] = useState<string | null>(null);
|
const [pendingEditId, setPendingEditId] = useState<string | null>(null);
|
||||||
|
const [runningId, setRunningId] = useState<string | null>(null);
|
||||||
|
|
||||||
const isEdit = useMemo(() => !!form._id, [form._id]);
|
const isEdit = useMemo(() => !!form._id, [form._id]);
|
||||||
|
|
||||||
@@ -90,7 +90,10 @@ export function JobsPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatCountdown = (job: Job, stream: ReturnType<typeof useLiveCounter>["jobStreams"][string]) => {
|
const formatCountdown = (job: Job, stream: ReturnType<typeof useLiveData>["jobStreams"][string]) => {
|
||||||
|
if (stream?.status === "running") {
|
||||||
|
return "00:00:00";
|
||||||
|
}
|
||||||
const lastRunAt = stream?.lastRunAt || job.lastRunAt;
|
const lastRunAt = stream?.lastRunAt || job.lastRunAt;
|
||||||
if (!lastRunAt) return "00:00:00";
|
if (!lastRunAt) return "00:00:00";
|
||||||
const baseMs =
|
const baseMs =
|
||||||
@@ -156,18 +159,15 @@ export function JobsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleRun = async (id: string) => {
|
||||||
const ok = window.confirm("Bu job'ı silmek istediğinize emin misiniz?");
|
setRunningId(id);
|
||||||
if (!ok) return;
|
|
||||||
setDeletingId(id);
|
|
||||||
try {
|
try {
|
||||||
await deleteJob(id);
|
await runJob(id);
|
||||||
setJobs((prev) => prev.filter((j) => j._id !== id));
|
toast.success("Job çalıştırılıyor");
|
||||||
toast.success("Job silindi");
|
} catch {
|
||||||
} catch (err) {
|
toast.error("Job çalıştırılamadı");
|
||||||
toast.error("Silme sırasında hata oluştu");
|
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setRunningId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,10 +186,6 @@ export function JobsPage() {
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-foreground">Jobs</h2>
|
<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>
|
</div>
|
||||||
<Button onClick={handleOpenNew} className="gap-2">
|
<Button onClick={handleOpenNew} className="gap-2">
|
||||||
<FontAwesomeIcon icon={faPlus} className="h-4 w-4" />
|
<FontAwesomeIcon icon={faPlus} className="h-4 w-4" />
|
||||||
@@ -239,24 +235,15 @@ 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"
|
||||||
|
disabled={runningId === job._id}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleEdit(job);
|
handleRun(job._id);
|
||||||
}}
|
}}
|
||||||
|
title="Testi çalıştır"
|
||||||
|
aria-label="Testi çalıştır"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPen} className="h-4 w-4 text-foreground" />
|
<FontAwesomeIcon icon={faPlay} className="h-4 w-4 text-foreground" />
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-10 w-10 transition hover:bg-red-100"
|
|
||||||
disabled={deletingId === job._id}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDelete(job._id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-foreground" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
|
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { useSocket } from "./socket-provider";
|
import { useSocket } from "./socket-provider";
|
||||||
|
|
||||||
type LiveState = {
|
|
||||||
value: number;
|
|
||||||
running: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type JobStream = {
|
type JobStream = {
|
||||||
logs: string[];
|
logs: string[];
|
||||||
status?: string;
|
status?: string;
|
||||||
lastRunAt?: string;
|
lastRunAt?: string;
|
||||||
lastMessage?: string;
|
lastMessage?: string;
|
||||||
runCount?: number;
|
runCount?: number;
|
||||||
|
lastDurationMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LiveContextValue = LiveState & {
|
type LiveContextValue = {
|
||||||
startCounter: () => void;
|
|
||||||
stopCounter: () => void;
|
|
||||||
jobStreams: Record<string, JobStream>;
|
jobStreams: Record<string, JobStream>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,22 +18,11 @@ 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 [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({});
|
const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
|
|
||||||
const handleUpdate = (payload: { value: number }) => {
|
|
||||||
setState({ value: payload.value, running: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStopped = (payload: { value: number }) => {
|
|
||||||
setState({ value: payload.value, running: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on("counter:update", handleUpdate);
|
|
||||||
socket.on("counter:stopped", handleStopped);
|
|
||||||
const handleJobLog = ({ jobId, line }: { jobId: string; line: string }) => {
|
const handleJobLog = ({ jobId, line }: { jobId: string; line: string }) => {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
setJobStreams((prev) => {
|
setJobStreams((prev) => {
|
||||||
@@ -54,67 +37,45 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
status,
|
status,
|
||||||
lastRunAt,
|
lastRunAt,
|
||||||
lastMessage,
|
lastMessage,
|
||||||
runCount
|
runCount,
|
||||||
|
lastDurationMs
|
||||||
}: {
|
}: {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
lastRunAt?: string;
|
lastRunAt?: string;
|
||||||
lastMessage?: string;
|
lastMessage?: string;
|
||||||
runCount?: number;
|
runCount?: number;
|
||||||
|
lastDurationMs?: number;
|
||||||
}) => {
|
}) => {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
setJobStreams((prev) => {
|
setJobStreams((prev) => {
|
||||||
const current = prev[jobId] || { logs: [] };
|
const current = prev[jobId] || { logs: [] };
|
||||||
return { ...prev, [jobId]: { ...current, status, lastRunAt, lastMessage, runCount } };
|
return { ...prev, [jobId]: { ...current, status, lastRunAt, lastMessage, runCount, lastDurationMs } };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.emit("counter:status", (payload: { value: number; running: boolean }) => {
|
|
||||||
setState({ value: payload.value, running: payload.running });
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("job:log", handleJobLog);
|
socket.on("job:log", handleJobLog);
|
||||||
socket.on("job:status", handleJobStatus);
|
socket.on("job:status", handleJobStatus);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("counter:update", handleUpdate);
|
|
||||||
socket.off("counter:stopped", handleStopped);
|
|
||||||
socket.off("job:log", handleJobLog);
|
socket.off("job:log", handleJobLog);
|
||||||
socket.off("job:status", handleJobStatus);
|
socket.off("job:status", handleJobStatus);
|
||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
const startCounter = useMemo(
|
|
||||||
() => () => {
|
|
||||||
socket?.emit("counter:start");
|
|
||||||
},
|
|
||||||
[socket]
|
|
||||||
);
|
|
||||||
|
|
||||||
const stopCounter = useMemo(
|
|
||||||
() => () => {
|
|
||||||
socket?.emit("counter:stop");
|
|
||||||
},
|
|
||||||
[socket]
|
|
||||||
);
|
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
value: state.value,
|
|
||||||
running: state.running,
|
|
||||||
startCounter,
|
|
||||||
stopCounter,
|
|
||||||
jobStreams
|
jobStreams
|
||||||
}),
|
}),
|
||||||
[state, startCounter, stopCounter, jobStreams]
|
[jobStreams]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
|
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useLiveCounter() {
|
export function useLiveData() {
|
||||||
const ctx = useContext(LiveContext);
|
const ctx = useContext(LiveContext);
|
||||||
if (!ctx) throw new Error("useLiveCounter LiveProvider içinde kullanılmalı");
|
if (!ctx) throw new Error("useLiveData LiveProvider içinde kullanılmalı");
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user