Initial commit

This commit is contained in:
Zdeněk Burda
2026-01-09 21:26:40 +01:00
parent e83aec6dca
commit 41e3ce6f25
404 changed files with 61250 additions and 28 deletions

View File

@@ -0,0 +1,326 @@
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,
};
}

View File

@@ -0,0 +1,151 @@
import { useEffect, useState } from "react";
import axios from "axios";
export type BandOption = {
id: number;
name: string;
order?: number | null;
has_power_category?: boolean | null;
};
export type EdiCategoryOption = {
id: number;
value: string;
};
export type EdiBandOption = {
id: number;
value: string;
bands?: { id: number; name: string }[];
};
type SelectedRoundLike = {
id?: number | null;
bands?: BandOption[];
} | null;
export const useRoundMeta = (roundId: number | null, selectedRound: SelectedRoundLike) => {
const [bands, setBands] = useState<BandOption[]>([]);
const [bandsError, setBandsError] = useState<string | null>(null);
const [bandsLoading, setBandsLoading] = useState(false);
const [ediCategories, setEdiCategories] = useState<EdiCategoryOption[]>([]);
const [ediCategoriesError, setEdiCategoriesError] = useState<string | null>(null);
const [ediCategoriesLoading, setEdiCategoriesLoading] = useState(false);
const [ediBands, setEdiBands] = useState<EdiBandOption[]>([]);
// načti dostupné bandy pro select PBand
useEffect(() => {
let active = true;
(async () => {
try {
setBandsLoading(true);
setBandsError(null);
let bandList: BandOption[] = [];
if (roundId && selectedRound?.id === roundId && Array.isArray(selectedRound.bands)) {
bandList = selectedRound.bands as BandOption[];
}
if (roundId) {
try {
const roundRes = await axios.get<any>(`/api/rounds/${roundId}`, {
headers: { Accept: "application/json" },
withCredentials: true,
});
if (Array.isArray(roundRes.data?.bands) && roundRes.data.bands.length > 0) {
bandList = roundRes.data.bands;
}
} catch {
// fallback handled below
}
}
if (!bandList.length) {
const res = await axios.get<BandOption[] | { data: BandOption[] }>("/api/bands", {
headers: { Accept: "application/json" },
withCredentials: true,
});
bandList = Array.isArray(res.data) ? res.data : (res.data as any)?.data ?? [];
}
if (!active) return;
const sorted = [...bandList].sort((a, b) => {
const aOrder = a.order ?? 0;
const bOrder = b.order ?? 0;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.name.localeCompare(b.name);
});
setBands(sorted);
} catch {
if (!active) return;
setBandsError("Nepodařilo se načíst seznam pásem.");
} finally {
if (active) setBandsLoading(false);
}
})();
return () => {
active = false;
};
}, [roundId, selectedRound]);
// načti dostupné EDI kategorie pro validaci PSect
useEffect(() => {
let active = true;
(async () => {
try {
setEdiCategoriesLoading(true);
setEdiCategoriesError(null);
const res = await axios.get<EdiCategoryOption[] | { data: EdiCategoryOption[] }>("/api/edi-categories", {
headers: { Accept: "application/json" },
withCredentials: true,
});
if (!active) return;
const raw = Array.isArray(res.data) ? res.data : (res.data as any)?.data ?? [];
const sorted = [...raw].sort((a, b) => a.value.localeCompare(b.value));
setEdiCategories(sorted);
} catch {
if (!active) return;
setEdiCategoriesError("Nepodařilo se načíst seznam kategorií.");
} finally {
if (active) setEdiCategoriesLoading(false);
}
})();
return () => {
active = false;
};
}, []);
// načti EDI bandy pro mapování PBand -> Band
useEffect(() => {
let active = true;
(async () => {
try {
const res = await axios.get<EdiBandOption[] | { data: EdiBandOption[] }>("/api/edi-bands", {
headers: { Accept: "application/json" },
withCredentials: true,
params: { per_page: 500 },
});
if (!active) return;
const raw = Array.isArray(res.data) ? res.data : (res.data as any)?.data ?? [];
setEdiBands(raw);
} catch {
if (!active) return;
// nechávám tiché selhání, mapování je nice-to-have
}
})();
return () => {
active = false;
};
}, []);
return {
bands,
bandsError,
bandsLoading,
ediCategories,
ediCategoriesError,
ediCategoriesLoading,
ediBands,
};
};