"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; import { Card, CardContent, CardTitle } from "@kandev/ui/card"; import { Spinner } from "@kandev/ui/spinner"; import { IconAlertTriangle, IconDownload, IconFileZip } from "@tabler/icons-react"; import { ApiError } from "@/lib/api/client"; import { createDesktopV1Adapter } from "@/lib/desktop/adapter"; import type { DesktopDownloadFeedback } from "@/lib/desktop/protocol"; import { createTauriEventTransport } from "@/lib/desktop/tauri-event-transport"; import { DIAGNOSTIC_BUNDLE_FILE_NAME } from "@/lib/desktop/download-feedback"; import { triggerUrlDownload } from "@/lib/utils/file-download"; import { buildDiagnosticBundleDownloadUrl, createDiagnosticBundle, fetchDiagnosticACPSessions, fetchDiagnosticBundle, fetchDiagnosticBundleCapabilities, } from "@/lib/api/domains/system-api"; import type { DiagnosticBundleCapabilities, DiagnosticBundleJob, DiagnosticBundleSource, DiagnosticSession, } from "@/lib/types/system"; import { BundleCustomizer } from "./bundle-customizer"; type ViewState = "idle" | "collecting" | "preparing" | "saving" | "partial" | "busy" | "error"; type ActiveDownload = { url: string; partial: boolean; warning: string; timeoutId?: number }; type ActiveDownloadRef = { current: ActiveDownload | null }; const DESKTOP_DOWNLOAD_FEEDBACK_TIMEOUT_MS = 10 * 60 * 1_000; const DESKTOP_DOWNLOAD_SELECTION_TIMEOUT_MS = 30 * 60 * 1_000; const DEFAULT_SOURCES: DiagnosticBundleSource[] = ["backend", "frontend"]; const desktopDownloadAdapter = createDesktopV1Adapter(createTauriEventTransport()); // eslint-disable-next-line max-lines-per-function -- coordinates bundle polling and shared customizer state. export function LogViewer() { const { t } = useTranslation(); const [state, setState] = useState("idle"); const [message, setMessage] = useState(""); const [capabilities, setCapabilities] = useState(null); const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionsLoaded, setSessionsLoaded] = useState(false); const [customizerOpen, setCustomizerOpen] = useState(false); const [selectedSources, setSelectedSources] = useState(DEFAULT_SOURCES); const [selectedSessionIDs, setSelectedSessionIDs] = useState([]); const [customizerSubmitting, setCustomizerSubmitting] = useState(false); const mounted = useRef(true); const activeDesktopDownload = useRef(null); const desktopDownloadListenerReady = useRef(false); const desktopDownloadListenerPromise = useRef | null>(null); const desktopDownloadListenerStop = useRef<(() => void) | null>(null); const desktopDownloadListenerGeneration = useRef(0); const ensureDesktopDownloadListener = useCallback((): Promise => { if (!desktopDownloadAdapter.isAvailable()) return Promise.resolve(false); if (desktopDownloadListenerReady.current) return Promise.resolve(true); if (desktopDownloadListenerPromise.current) return desktopDownloadListenerPromise.current; const generation = desktopDownloadListenerGeneration.current; const promise = desktopDownloadAdapter .listen("download", (feedback) => { const active = activeDesktopDownload.current; if (!mounted.current || !active || feedback.url !== active.url) return; handleDesktopDownloadFeedback(feedback, active, { setState, setMessage, t, activeDesktopDownload, }); }) .then( (unlisten) => { if (!mounted.current || generation !== desktopDownloadListenerGeneration.current) { unlisten(); return false; } desktopDownloadListenerReady.current = true; desktopDownloadListenerStop.current = unlisten; return true; }, () => false, ) .finally(() => { if (desktopDownloadListenerPromise.current === promise) { desktopDownloadListenerPromise.current = null; } }); desktopDownloadListenerPromise.current = promise; return promise; }, [t]); useEffect(() => { mounted.current = true; void fetchDiagnosticBundleCapabilities() .then((next) => mounted.current && setCapabilities(next)) .catch(() => undefined); return () => { mounted.current = false; clearActiveDesktopDownload(activeDesktopDownload); }; }, []); useEffect(() => { if (!desktopDownloadAdapter.isAvailable()) return; desktopDownloadListenerGeneration.current += 1; void ensureDesktopDownloadListener(); return () => { desktopDownloadListenerGeneration.current += 1; desktopDownloadListenerReady.current = false; desktopDownloadListenerPromise.current = null; desktopDownloadListenerStop.current?.(); desktopDownloadListenerStop.current = null; }; }, [ensureDesktopDownloadListener]); useEffect(() => { if (!customizerOpen || !selectedSources.includes("acp") || sessionsLoaded) return; setSessionsLoading(true); void fetchDiagnosticACPSessions() .then((next) => { if (!mounted.current) return; setSessions(next); setSessionsLoaded(true); }) .catch(() => { if (!mounted.current) return; setSessions([]); setSessionsLoaded(true); }) .finally(() => mounted.current && setSessionsLoading(false)); }, [customizerOpen, selectedSources, sessionsLoaded]); const download = async (sources: DiagnosticBundleSource[], sessionIDs: string[] = []) => { setMessage(""); setState("collecting"); try { const job = await prepareDiagnosticBundle(sources, sessionIDs, (next) => { if (mounted.current) setState(next); }); if (!mounted.current) return; if (job.status !== "ready" && job.status !== "partial") { throw new Error(job.warnings?.[0] ?? t("settings:diagnosticBundlePrepareError")); } await startBundleDownload(job, buildDiagnosticBundleDownloadUrl(job.id), { activeDesktopDownload, setState, setMessage, t, ensureDesktopDownloadListener, isMounted: () => mounted.current, }); } catch (error) { if (!mounted.current) return; clearActiveDesktopDownload(activeDesktopDownload); if (error instanceof ApiError && (error.status === 429 || error.status === 503)) { const retry = error.retryAfterSeconds ?? 5; setState("busy"); setMessage(t("settings:diagnosticBusy", { seconds: retry })); window.setTimeout(() => mounted.current && setState("idle"), retry * 1_000); return; } setState("error"); setMessage(error instanceof Error ? error.message : t("settings:diagnosticBundleFailed")); } }; const openCustomizer = () => { const available = new Set(capabilities?.sources ?? DEFAULT_SOURCES); setSelectedSources(DEFAULT_SOURCES.filter((source) => available.has(source))); setSelectedSessionIDs([]); setCustomizerSubmitting(false); setCustomizerOpen(true); }; const submitCustomizer = () => { setCustomizerSubmitting(true); setCustomizerOpen(false); void download(selectedSources, selectedSessionIDs).finally(() => { if (mounted.current) setCustomizerSubmitting(false); }); }; const pending = state === "collecting" || state === "preparing" || state === "saving"; return (
{ setSelectedSources(sources); if (!sources.includes("acp")) setSelectedSessionIDs([]); }} onSessionChange={(sessionID, checked) => setSelectedSessionIDs((current) => updateSessionSelection(current, sessionID, checked)) } onSessionSelectionChange={setSelectedSessionIDs} onSubmit={submitCustomizer} />
); } function DiagnosticDisclosure() { const { t } = useTranslation(); return ( {t("settings:diagnosticReviewBeforeSharing")}

{t("settings:diagnosticReviewDescription")}

{t("settings:diagnosticNoMessages")}

{t("settings:diagnosticIncidentalText")}

); } type DiagnosticBundleCardProps = { pending: boolean; state: ViewState; message: string; onCustomize: () => void; }; function DiagnosticBundleCard({ pending, state, message, onCustomize }: DiagnosticBundleCardProps) { const { t } = useTranslation(); return (
{t("settings:diagnosticBundleTitle")}

{t("settings:diagnosticBundleDescription")}

{t("settings:diagnosticBackendEvents")}

{t("settings:diagnosticFrontendEvents")}

{message && }
); } function DiagnosticBundleActions({ pending, state, onCustomize, }: Omit) { const { t } = useTranslation(); return (
); } function DiagnosticBundleMessage({ state, message }: { state: ViewState; message: string }) { return (

{message}

); } function updateSessionSelection(current: string[], sessionID: string, checked: boolean): string[] { if (!checked) return current.filter((id) => id !== sessionID); if (current.includes(sessionID)) return current; return [...current, sessionID]; } async function prepareDiagnosticBundle( sources: DiagnosticBundleSource[], sessionIDs: string[], onStatus: (state: ViewState) => void, ) { let job = await createDiagnosticBundle(sources, sessionIDs); while (job.status === "collecting" || job.status === "building") { onStatus(job.status === "collecting" ? "collecting" : "preparing"); await delay(500); job = await fetchDiagnosticBundle(job.id); } return job; } function buttonLabel(state: ViewState, t: (key: string) => string): string { if (state === "collecting") return t("settings:diagnosticCollectingFrontendLogs"); if (state === "preparing") return t("settings:diagnosticPreparingZip"); if (state === "saving") return t("settings:saving"); return t("settings:diagnosticCustomize"); } type DesktopDownloadActions = { activeDesktopDownload: ActiveDownloadRef; setState: (state: ViewState) => void; setMessage: (message: string) => void; t: (key: string) => string; }; function clearActiveDesktopDownload(activeRef: ActiveDownloadRef): void { const active = activeRef.current; if (active?.timeoutId !== undefined) window.clearTimeout(active.timeoutId); activeRef.current = null; } function failActiveDesktopDownload(active: ActiveDownload, actions: DesktopDownloadActions): void { if (actions.activeDesktopDownload.current !== active) return; clearActiveDesktopDownload(actions.activeDesktopDownload); actions.setState("error"); actions.setMessage( withPartialWarning(actions.t("settings:diagnosticSaveFailed"), active.warning), ); } function armDesktopDownloadFeedbackTimeout( active: ActiveDownload, actions: DesktopDownloadActions, timeout = DESKTOP_DOWNLOAD_FEEDBACK_TIMEOUT_MS, ): void { if (active.timeoutId !== undefined) window.clearTimeout(active.timeoutId); active.timeoutId = window.setTimeout(() => failActiveDesktopDownload(active, actions), timeout); } function handleDesktopDownloadFeedback( feedback: DesktopDownloadFeedback, active: ActiveDownload, actions: DesktopDownloadActions, ): void { if (feedback.status === "selecting") { armDesktopDownloadFeedbackTimeout(active, actions, DESKTOP_DOWNLOAD_SELECTION_TIMEOUT_MS); actions.setMessage(actions.t("settings:diagnosticSavePending")); return; } if (feedback.status === "started") { armDesktopDownloadFeedbackTimeout(active, actions); actions.setMessage(actions.t("settings:diagnosticSavePending")); return; } if (feedback.status === "cancelled") { actions.setState(active.partial ? "partial" : "idle"); actions.setMessage(""); } else if (feedback.status === "saved") { actions.setState(active.partial ? "partial" : "idle"); actions.setMessage( withPartialWarning(actions.t("settings:diagnosticSaveComplete"), active.warning), ); } else { actions.setState("error"); actions.setMessage( withPartialWarning(actions.t("settings:diagnosticSaveFailed"), active.warning), ); } clearActiveDesktopDownload(actions.activeDesktopDownload); } async function startBundleDownload( job: DiagnosticBundleJob, downloadUrl: string, actions: { activeDesktopDownload: ActiveDownloadRef; setState: (state: ViewState) => void; setMessage: (message: string) => void; t: (key: string) => string; ensureDesktopDownloadListener: () => Promise; isMounted: () => boolean; }, ): Promise { if (desktopDownloadAdapter.isAvailable()) { if (!(await actions.ensureDesktopDownloadListener())) { clearActiveDesktopDownload(actions.activeDesktopDownload); actions.setState("error"); actions.setMessage(actions.t("settings:diagnosticSaveFailed")); return; } if (!actions.isMounted()) return; const active: ActiveDownload = { url: new URL(downloadUrl, window.location.href).href, partial: job.status === "partial", warning: bundleWarning(job, actions.t), }; actions.activeDesktopDownload.current = active; armDesktopDownloadFeedbackTimeout(active, actions); actions.setState("saving"); actions.setMessage(actions.t("settings:diagnosticSavePending")); } else { clearActiveDesktopDownload(actions.activeDesktopDownload); actions.setState(job.status === "partial" ? "partial" : "idle"); actions.setMessage(bundleMessage(job, actions.t)); } triggerUrlDownload(downloadUrl, DIAGNOSTIC_BUNDLE_FILE_NAME); } function bundleWarning(job: DiagnosticBundleJob, t: (key: string) => string): string { if (job.status !== "partial") return ""; return job.warnings?.length ? job.warnings.join(" ") : t("settings:diagnosticPartialZipWarning"); } function withPartialWarning(message: string, warning: string): string { return warning ? `${message} ${warning}` : message; } function bundleMessage( job: DiagnosticBundleJob, t: (key: string, options?: Record) => string, ): string { if (job.status !== "partial") return t("settings:diagnosticZipDownloading"); return job.warnings?.length ? t("settings:diagnosticPartialZipDownloading", { warnings: job.warnings.join(" ") }) : t("settings:diagnosticPartialZipUnavailable"); } function delay(milliseconds: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); }