Files
kandev/apps/web/components/settings/system/log-viewer.tsx
T
2026-09-25 18:07:55 +01:00

456 lines
17 KiB
TypeScript

"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<ViewState>("idle");
const [message, setMessage] = useState("");
const [capabilities, setCapabilities] = useState<DiagnosticBundleCapabilities | null>(null);
const [sessions, setSessions] = useState<DiagnosticSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionsLoaded, setSessionsLoaded] = useState(false);
const [customizerOpen, setCustomizerOpen] = useState(false);
const [selectedSources, setSelectedSources] = useState<DiagnosticBundleSource[]>(DEFAULT_SOURCES);
const [selectedSessionIDs, setSelectedSessionIDs] = useState<string[]>([]);
const [customizerSubmitting, setCustomizerSubmitting] = useState(false);
const mounted = useRef(true);
const activeDesktopDownload = useRef<ActiveDownload | null>(null);
const desktopDownloadListenerReady = useRef(false);
const desktopDownloadListenerPromise = useRef<Promise<boolean> | null>(null);
const desktopDownloadListenerStop = useRef<(() => void) | null>(null);
const desktopDownloadListenerGeneration = useRef(0);
const ensureDesktopDownloadListener = useCallback((): Promise<boolean> => {
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 (
<div className="min-w-0 space-y-4">
<DiagnosticDisclosure />
<DiagnosticBundleCard
pending={pending}
state={state}
message={message}
onCustomize={openCustomizer}
/>
<BundleCustomizer
open={customizerOpen}
onOpenChange={setCustomizerOpen}
capabilities={capabilities}
sessions={sessions}
sessionsLoading={sessionsLoading}
selectedSources={selectedSources}
selectedSessionIDs={selectedSessionIDs}
submitting={customizerSubmitting}
onSourcesChange={(sources) => {
setSelectedSources(sources);
if (!sources.includes("acp")) setSelectedSessionIDs([]);
}}
onSessionChange={(sessionID, checked) =>
setSelectedSessionIDs((current) => updateSessionSelection(current, sessionID, checked))
}
onSessionSelectionChange={setSelectedSessionIDs}
onSubmit={submitCustomizer}
/>
</div>
);
}
function DiagnosticDisclosure() {
const { t } = useTranslation();
return (
<Alert className="border-border/70 bg-muted/30 p-5 sm:p-6">
<IconAlertTriangle className="size-4" />
<AlertTitle>{t("settings:diagnosticReviewBeforeSharing")}</AlertTitle>
<AlertDescription className="space-y-4 text-sm leading-relaxed">
<p>{t("settings:diagnosticReviewDescription")}</p>
<div className="space-y-3 border-t border-border/70 pt-4 text-xs leading-relaxed">
<p>{t("settings:diagnosticNoMessages")}</p>
<p className="border-t border-border/70 pt-3">{t("settings:diagnosticIncidentalText")}</p>
</div>
</AlertDescription>
</Alert>
);
}
type DiagnosticBundleCardProps = {
pending: boolean;
state: ViewState;
message: string;
onCustomize: () => void;
};
function DiagnosticBundleCard({ pending, state, message, onCustomize }: DiagnosticBundleCardProps) {
const { t } = useTranslation();
return (
<Card data-testid="system-diagnostic-bundle-card" className="border-border/70">
<CardContent className="space-y-5 p-5 sm:p-6">
<div className="max-w-3xl space-y-4">
<div className="space-y-2">
<CardTitle className="flex items-center gap-2 text-base">
<IconFileZip className="size-4" />
{t("settings:diagnosticBundleTitle")}
</CardTitle>
<p className="text-sm leading-relaxed text-muted-foreground">
{t("settings:diagnosticBundleDescription")}
</p>
</div>
<DiagnosticBundleActions pending={pending} state={state} onCustomize={onCustomize} />
</div>
<div className="space-y-3 border-t border-border/70 pt-4 text-xs leading-relaxed text-muted-foreground">
<p>{t("settings:diagnosticBackendEvents")}</p>
<p className="border-t border-border/70 pt-3">{t("settings:diagnosticFrontendEvents")}</p>
</div>
{message && <DiagnosticBundleMessage state={state} message={message} />}
</CardContent>
</Card>
);
}
function DiagnosticBundleActions({
pending,
state,
onCustomize,
}: Omit<DiagnosticBundleCardProps, "message">) {
const { t } = useTranslation();
return (
<div className="flex w-full sm:w-auto">
<Button
className="w-full cursor-pointer sm:w-auto"
disabled={pending}
onClick={onCustomize}
data-testid="customize-diagnostic-bundle"
>
{pending ? <Spinner className="mr-2 size-4" /> : <IconDownload className="mr-2 size-4" />}
{pending ? buttonLabel(state, t) : t("settings:diagnosticCustomize")}
</Button>
</div>
);
}
function DiagnosticBundleMessage({ state, message }: { state: ViewState; message: string }) {
return (
<p
className={state === "error" ? "text-sm text-destructive" : "text-sm text-muted-foreground"}
role={state === "error" ? "alert" : "status"}
data-testid="diagnostic-bundle-status"
>
{message}
</p>
);
}
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<boolean>;
isMounted: () => boolean;
},
): Promise<void> {
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, unknown>) => 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<void> {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}