327 lines
9.3 KiB
TypeScript
327 lines
9.3 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import axios from "axios";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
export type EvaluationRun = {
|
|
id: number;
|
|
round_id: number;
|
|
name?: string | null;
|
|
rules_version?: string | null;
|
|
result_type?: string | null;
|
|
status?: string | null;
|
|
current_step?: string | null;
|
|
created_at?: string | null;
|
|
started_at?: string | null;
|
|
finished_at?: string | null;
|
|
progress_total?: number | null;
|
|
progress_done?: number | null;
|
|
};
|
|
|
|
export type EvaluationRunEvent = {
|
|
id: number;
|
|
level: string;
|
|
message: string;
|
|
context?: Record<string, unknown> | null;
|
|
created_at?: string | null;
|
|
};
|
|
|
|
type PaginatedResponse<T> = {
|
|
data: T[];
|
|
current_page: number;
|
|
last_page: number;
|
|
total: number;
|
|
};
|
|
|
|
const isActiveStatus = (status?: string | null) =>
|
|
status === "PENDING" ||
|
|
status === "RUNNING" ||
|
|
status === "WAITING_REVIEW_INPUT" ||
|
|
status === "WAITING_REVIEW_MATCH" ||
|
|
status === "WAITING_REVIEW_SCORE";
|
|
|
|
const isOfficialRun = (run: EvaluationRun | null) =>
|
|
!!run && run.rules_version !== "CLAIMED";
|
|
|
|
export const steps = [
|
|
{ key: "prepare", label: "Prepare" },
|
|
{ key: "parse_logs", label: "Parse" },
|
|
{ key: "build_working_set", label: "Working set" },
|
|
{ key: "waiting_review_input", label: "Review input" },
|
|
{ key: "match", label: "Match" },
|
|
{ key: "waiting_review_match", label: "Review match" },
|
|
{ key: "score", label: "Score" },
|
|
{ key: "aggregate", label: "Aggregate" },
|
|
{ key: "waiting_review_score", label: "Review score" },
|
|
{ key: "finalize", label: "Finalize" },
|
|
];
|
|
|
|
export default function useRoundEvaluationRun(roundId: number | null) {
|
|
const { i18n } = useTranslation();
|
|
const [run, setRun] = useState<EvaluationRun | null>(null);
|
|
const [runs, setRuns] = useState<EvaluationRun[]>([]);
|
|
const [events, setEvents] = useState<EvaluationRunEvent[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [actionLoading, setActionLoading] = useState(false);
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [pendingMessageClear, setPendingMessageClear] = useState(false);
|
|
const [lastKnownStep, setLastKnownStep] = useState<string | null>(null);
|
|
const [hasLoaded, setHasLoaded] = useState(false);
|
|
|
|
const load = async (opts?: { silent?: boolean }) => {
|
|
if (!roundId) return;
|
|
try {
|
|
if (!opts?.silent) {
|
|
setLoading(true);
|
|
}
|
|
if (!opts?.silent) {
|
|
setError(null);
|
|
}
|
|
const res = await axios.get<PaginatedResponse<EvaluationRun>>("/api/evaluation-runs", {
|
|
headers: { Accept: "application/json" },
|
|
params: { round_id: roundId, per_page: 20 },
|
|
withCredentials: true,
|
|
});
|
|
const officialRuns = res.data.data.filter((item) => item.rules_version !== "CLAIMED");
|
|
const limitedRuns = officialRuns.slice(0, 3);
|
|
const latest = limitedRuns[0] ?? null;
|
|
setRuns(limitedRuns);
|
|
setRun(latest);
|
|
setHasLoaded(true);
|
|
if (pendingMessageClear) {
|
|
setMessage(null);
|
|
setPendingMessageClear(false);
|
|
}
|
|
} catch (e: any) {
|
|
if (!opts?.silent) {
|
|
const msg = e?.response?.data?.message || "Nepodařilo se načíst stav vyhodnocení.";
|
|
setError(msg);
|
|
}
|
|
} finally {
|
|
if (!opts?.silent) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const fetchEvents = async (runId: number) => {
|
|
try {
|
|
const res = await axios.get<EvaluationRunEvent[]>(
|
|
`/api/evaluation-runs/${runId}/events`,
|
|
{
|
|
headers: { Accept: "application/json" },
|
|
params: { limit: 8, min_level: "warning" },
|
|
withCredentials: true,
|
|
}
|
|
);
|
|
setEvents(res.data);
|
|
} catch {
|
|
setEvents([]);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [roundId]);
|
|
|
|
useEffect(() => {
|
|
if (!run || !isOfficialRun(run)) return;
|
|
fetchEvents(run.id);
|
|
}, [run?.id]);
|
|
|
|
const canStart = useMemo(() => {
|
|
if (!roundId) return false;
|
|
if (!run) return true;
|
|
return !isActiveStatus(run.status);
|
|
}, [roundId, run]);
|
|
|
|
const canResume =
|
|
run?.status === "WAITING_REVIEW_INPUT" ||
|
|
run?.status === "WAITING_REVIEW_MATCH" ||
|
|
run?.status === "WAITING_REVIEW_SCORE";
|
|
const canCancel = run && isActiveStatus(run.status);
|
|
const shouldPollRun =
|
|
run && isOfficialRun(run) && (run.status === "RUNNING" || run.status === "PENDING");
|
|
const shouldPollEvents = shouldPollRun;
|
|
|
|
const currentStepIndex = run
|
|
? steps.findIndex((step) => step.key === (run.current_step ?? lastKnownStep))
|
|
: -1;
|
|
const isSucceeded = run?.status === "SUCCEEDED";
|
|
const stepProgressPercent =
|
|
run && run.progress_total && run.progress_total > 0
|
|
? Math.round((run.progress_done / run.progress_total) * 100)
|
|
: null;
|
|
|
|
useEffect(() => {
|
|
if (!run || !shouldPollEvents) return;
|
|
const interval = window.setInterval(() => {
|
|
fetchEvents(run.id);
|
|
}, 1000);
|
|
return () => window.clearInterval(interval);
|
|
}, [run?.id, shouldPollEvents]);
|
|
|
|
useEffect(() => {
|
|
if (!run || !shouldPollRun) return;
|
|
const interval = window.setInterval(() => {
|
|
load({ silent: true });
|
|
}, 1000);
|
|
return () => window.clearInterval(interval);
|
|
}, [run?.id, shouldPollRun]);
|
|
|
|
useEffect(() => {
|
|
if (run?.current_step) {
|
|
setLastKnownStep(run.current_step);
|
|
}
|
|
}, [run?.current_step]);
|
|
|
|
const formatEventTime = (value?: string | null) => {
|
|
if (!value) return null;
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
const locale = i18n.language?.startsWith("cs") ? "cs-CZ" : "en-US";
|
|
return new Intl.DateTimeFormat(locale, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}).format(date);
|
|
};
|
|
|
|
const handleStart = async () => {
|
|
if (!roundId) return;
|
|
try {
|
|
setActionLoading(true);
|
|
setMessage(null);
|
|
setError(null);
|
|
const res = await axios.post(`/api/rounds/${roundId}/evaluation-runs/start`, null, {
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
withXSRFToken: true,
|
|
});
|
|
setRun(res.data);
|
|
setMessage("Vyhodnocení bylo spuštěno.");
|
|
setPendingMessageClear(true);
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data?.message || "Nepodařilo se spustit vyhodnocení.";
|
|
setError(msg);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleStartIncremental = async () => {
|
|
if (!roundId) return;
|
|
try {
|
|
setActionLoading(true);
|
|
setMessage(null);
|
|
setError(null);
|
|
const res = await axios.post(`/api/rounds/${roundId}/evaluation-runs/start-incremental`, null, {
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
withXSRFToken: true,
|
|
});
|
|
setRun(res.data);
|
|
setMessage("Vyhodnocení bylo spuštěno znovu.");
|
|
setPendingMessageClear(true);
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data?.message || "Nepodařilo se spustit vyhodnocení znovu.";
|
|
setError(msg);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleResume = async () => {
|
|
if (!run) return;
|
|
try {
|
|
setActionLoading(true);
|
|
setMessage(null);
|
|
setError(null);
|
|
await axios.post(`/api/evaluation-runs/${run.id}/resume`, null, {
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
withXSRFToken: true,
|
|
});
|
|
setMessage("Pokračování bylo spuštěno.");
|
|
setPendingMessageClear(true);
|
|
await load();
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data?.message || "Nepodařilo se pokračovat.";
|
|
setError(msg);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCancel = async () => {
|
|
if (!run) return;
|
|
try {
|
|
setActionLoading(true);
|
|
setMessage(null);
|
|
setError(null);
|
|
await axios.post(`/api/evaluation-runs/${run.id}/cancel`, null, {
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
withXSRFToken: true,
|
|
});
|
|
setMessage("Vyhodnocení bylo zrušeno.");
|
|
await load();
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data?.message || "Nepodařilo se zrušit běh.";
|
|
setError(msg);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSetResultType = async (resultType: "PRELIMINARY" | "FINAL" | "TEST") => {
|
|
if (!run) return;
|
|
try {
|
|
setActionLoading(true);
|
|
setMessage(null);
|
|
setError(null);
|
|
const res = await axios.post(
|
|
`/api/evaluation-runs/${run.id}/result-type`,
|
|
{ result_type: resultType },
|
|
{
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
withXSRFToken: true,
|
|
}
|
|
);
|
|
setRun(res.data);
|
|
setMessage("Typ výsledků byl uložen.");
|
|
setPendingMessageClear(true);
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data?.message || "Nepodařilo se uložit typ výsledků.";
|
|
setError(msg);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
return {
|
|
run,
|
|
runs,
|
|
events,
|
|
loading,
|
|
actionLoading,
|
|
message,
|
|
error,
|
|
hasLoaded,
|
|
canStart,
|
|
canResume,
|
|
canCancel,
|
|
shouldPollRun,
|
|
isOfficialRun: isOfficialRun(run),
|
|
currentStepIndex,
|
|
isSucceeded,
|
|
stepProgressPercent,
|
|
formatEventTime,
|
|
handleStart,
|
|
handleStartIncremental,
|
|
handleResume,
|
|
handleCancel,
|
|
handleSetResultType,
|
|
};
|
|
}
|