import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import axios from "axios"; import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6"; import { LuX } from "react-icons/lu"; import ActivityIndicator from "../indicators/activity-indicator"; import { KeyframeAnalysis } from "@/types/stats"; const PROBE_WINDOW_SECONDS = 20; type KeyframeAnalysisSectionProps = { cameraName: string; onResult?: (analysis: KeyframeAnalysis) => void; }; export default function KeyframeAnalysisSection({ cameraName, onResult, }: KeyframeAnalysisSectionProps) { const { t } = useTranslation(["views/system"]); const [analysis, setAnalysis] = useState(); const [failed, setFailed] = useState(false); const [secondsRemaining, setSecondsRemaining] = useState(PROBE_WINDOW_SECONDS); // fire the probe once on mount useEffect(() => { let active = true; axios .get("keyframe_analysis", { params: { camera: cameraName } }) .then((res) => { if (active) { setAnalysis(res.data); onResult?.(res.data); } }) .catch(() => { if (active) { setFailed(true); } }); return () => { active = false; }; // re-probing only depends on the camera; onResult is a stable setter // eslint-disable-next-line react-hooks/exhaustive-deps }, [cameraName]); // countdown while waiting for the probe to return useEffect(() => { if (analysis || failed) { return; } const interval = setInterval(() => { setSecondsRemaining((s) => (s > 0 ? s - 1 : 0)); }, 1000); return () => clearInterval(interval); }, [analysis, failed]); const content = useMemo(() => { if (failed) { return {t("cameras.info.keyframes.unknown")}; } if (!analysis) { return (
{secondsRemaining > 0 ? t("cameras.info.keyframes.analyzing", { seconds: secondsRemaining, }) : t("cameras.info.keyframes.stillAnalyzing")}
); } let summary; switch (analysis.severity) { case "ok": summary = ( {t("cameras.info.keyframes.ok", { seconds: analysis.mean_gap })} ); break; case "warning": summary = ( {t("cameras.info.keyframes.warning", { seconds: analysis.max_gap })} ); break; case "error": summary = ( {t("cameras.info.keyframes.error", { seconds: analysis.max_gap, segmentTime: analysis.segment_time, })} ); break; case "record_disabled": summary = ( {t("cameras.info.keyframes.recordDisabled")} ); break; default: summary = ( {t("cameras.info.keyframes.unknown")} ); } // gap statistics are only meaningful once at least two keyframes were seen const hasStats = analysis.max_gap != null; const hasDetails = hasStats || analysis.stream_index != null; return (
{analysis.stream_index != null && (
{t("cameras.info.keyframes.recordStream")}{" "} {t("cameras.info.stream", { idx: analysis.stream_index + 1 })}
)} {hasStats && (
{t("cameras.info.keyframes.keyframeCount")}{" "} {analysis.keyframe_count}
{t("cameras.info.keyframes.observedDuration")}{" "} {analysis.duration_observed}s
{t("cameras.info.keyframes.gap")}{" "} {analysis.min_gap}s / {analysis.mean_gap}s / {analysis.max_gap}s
{t("cameras.info.keyframes.segmentLength")}{" "} {analysis.segment_time}s
)}
{summary}
); }, [analysis, failed, secondsRemaining, t]); return (
{t("cameras.info.keyframes.title")}
{content}
); } type RowProps = { icon: "ok" | "warning" | "error" | "unknown"; children: React.ReactNode; }; function Row({ icon, children }: RowProps) { return (
{icon === "ok" && ( )} {icon === "warning" && ( )} {icon === "error" && ( )} {icon === "unknown" && ( )} {children}
); }