Test modülü eklendi
This commit is contained in:
@@ -4,6 +4,7 @@ import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||
import { DashboardLayout } from "./components/DashboardLayout";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { JobsPage } from "./pages/JobsPage";
|
||||
import { JobDetailPage } from "./pages/JobDetailPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -13,6 +14,7 @@ function App() {
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/jobs/:id" element={<JobDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface Job {
|
||||
testCommand: string;
|
||||
checkValue: number;
|
||||
checkUnit: TimeUnit;
|
||||
status?: "idle" | "running" | "success" | "failed";
|
||||
lastRunAt?: string;
|
||||
lastDurationMs?: number;
|
||||
lastMessage?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -31,6 +35,11 @@ export async function createJob(payload: JobInput): Promise<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> {
|
||||
const { data } = await apiClient.put(`/jobs/${id}`, payload);
|
||||
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> {
|
||||
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() {
|
||||
return (
|
||||
<SonnerToaster
|
||||
position="top-right"
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
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 { 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 { 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";
|
||||
import { Label } from "../components/ui/label";
|
||||
@@ -16,6 +15,8 @@ import {
|
||||
} from "../components/ui/select";
|
||||
import { useLiveCounter } from "../providers/live-provider";
|
||||
import { createJob, deleteJob, fetchJobs, Job, JobInput, updateJob } from "../api/jobs";
|
||||
import { RepoIcon } from "../components/RepoIcon";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type FormState = {
|
||||
_id?: string;
|
||||
@@ -34,26 +35,9 @@ const defaultForm: FormState = {
|
||||
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 navigate = useNavigate();
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -175,7 +159,11 @@ export function JobsPage() {
|
||||
)}
|
||||
{!loading &&
|
||||
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">
|
||||
<div className="pt-2.5">
|
||||
<RepoIcon repoUrl={job.repoUrl} />
|
||||
@@ -188,7 +176,10 @@ export function JobsPage() {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
@@ -197,7 +188,10 @@ export function JobsPage() {
|
||||
size="icon"
|
||||
className="h-10 w-10 transition hover:bg-red-100"
|
||||
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" />
|
||||
</Button>
|
||||
|
||||
@@ -6,9 +6,17 @@ type LiveState = {
|
||||
running: boolean;
|
||||
};
|
||||
|
||||
type JobStream = {
|
||||
logs: string[];
|
||||
status?: string;
|
||||
lastRunAt?: string;
|
||||
lastMessage?: string;
|
||||
};
|
||||
|
||||
type LiveContextValue = LiveState & {
|
||||
startCounter: () => void;
|
||||
stopCounter: () => void;
|
||||
jobStreams: Record<string, JobStream>;
|
||||
};
|
||||
|
||||
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 }) => {
|
||||
const socket = useSocket();
|
||||
const [state, setState] = useState<LiveState>({ value: 0, running: false });
|
||||
const [jobStreams, setJobStreams] = useState<Record<string, JobStream>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
@@ -30,14 +39,45 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
socket.on("counter:update", handleUpdate);
|
||||
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 }) => {
|
||||
setState({ value: payload.value, running: payload.running });
|
||||
});
|
||||
|
||||
socket.on("job:log", handleJobLog);
|
||||
socket.on("job:status", handleJobStatus);
|
||||
|
||||
return () => {
|
||||
socket.off("counter:update", handleUpdate);
|
||||
socket.off("counter:stopped", handleStopped);
|
||||
socket.off("job:log", handleJobLog);
|
||||
socket.off("job:status", handleJobStatus);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
@@ -60,9 +100,10 @@ export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
value: state.value,
|
||||
running: state.running,
|
||||
startCounter,
|
||||
stopCounter
|
||||
stopCounter,
|
||||
jobStreams
|
||||
}),
|
||||
[state, startCounter, stopCounter]
|
||||
[state, startCounter, stopCounter, jobStreams]
|
||||
);
|
||||
|
||||
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ı");
|
||||
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