* feat(diagnostics): add app diagnostic report * fix(diagnostics): guard app diagnostic runs * fix(diagnostics): include websocket runtime metrics * fix(diagnostics): type websocket metric snapshot * fix(diagnostics): include daemon shell env * fix(loop): make stop cancellation test deterministic
440 lines
13 KiB
TypeScript
440 lines
13 KiB
TypeScript
import * as Clipboard from "expo-clipboard";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Platform,
|
|
Pressable,
|
|
ScrollView,
|
|
Text,
|
|
View,
|
|
type PressableStateCallbackType,
|
|
} from "react-native";
|
|
import { Copy, RotateCw } from "lucide-react-native";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import { useToast } from "@/contexts/toast-context";
|
|
import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
|
import {
|
|
formatAppDiagnosticHeader,
|
|
formatDiagnosticSection,
|
|
formatHostRuntimeSection,
|
|
formatServerInfoSection,
|
|
redactAppDiagnosticReport,
|
|
} from "@/diagnostics/app-diagnostic-report";
|
|
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
|
import { settingsStyles } from "@/styles/settings";
|
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|
import type { HostProfile } from "@/types/host-connection";
|
|
|
|
interface AppDiagnosticSheetProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
appVersion: string | null;
|
|
isDesktopApp: boolean;
|
|
}
|
|
|
|
type ProgressStatus = "pending" | "running" | "done" | "failed";
|
|
|
|
interface ProgressRow {
|
|
id: string;
|
|
label: string;
|
|
status: ProgressStatus;
|
|
}
|
|
|
|
interface DiagnosticCollectionResult {
|
|
sections: string[];
|
|
status: ProgressStatus;
|
|
}
|
|
|
|
const SNAP_POINTS = ["55%", "88%"];
|
|
const ThemedCopy = withUnistyles(Copy);
|
|
const ThemedRotateCw = withUnistyles(RotateCw);
|
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.foregroundMuted,
|
|
});
|
|
|
|
export function AppDiagnosticSheet({
|
|
visible,
|
|
onClose,
|
|
appVersion,
|
|
isDesktopApp,
|
|
}: AppDiagnosticSheetProps) {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const hosts = useHosts();
|
|
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [progress, setProgress] = useState<ProgressRow[]>([]);
|
|
const runIdRef = useRef(0);
|
|
|
|
const updateProgress = useCallback((id: string, label: string, status: ProgressStatus) => {
|
|
setProgress((current) => {
|
|
const existing = current.find((row) => row.id === id);
|
|
if (existing) {
|
|
return current.map((row) => (row.id === id ? { ...row, label, status } : row));
|
|
}
|
|
return [...current, { id, label, status }];
|
|
});
|
|
}, []);
|
|
|
|
const runDiagnostics = useCallback(async () => {
|
|
const runId = runIdRef.current + 1;
|
|
runIdRef.current = runId;
|
|
const isCurrentRun = () => runIdRef.current === runId;
|
|
const updateRunProgress = (id: string, label: string, status: ProgressStatus) => {
|
|
if (isCurrentRun()) {
|
|
updateProgress(id, label, status);
|
|
}
|
|
};
|
|
|
|
setLoading(true);
|
|
setDiagnostic(null);
|
|
setProgress([]);
|
|
|
|
const sections: string[] = [];
|
|
try {
|
|
updateRunProgress("client", t("settings.diagnostics.app.progress.client"), "running");
|
|
sections.push(
|
|
formatAppDiagnosticHeader({
|
|
appVersion,
|
|
platform: Platform.OS,
|
|
isDesktopApp,
|
|
hostCount: hosts.length,
|
|
}),
|
|
);
|
|
updateRunProgress("client", t("settings.diagnostics.app.progress.client"), "done");
|
|
|
|
if (isDesktopApp) {
|
|
const desktopLabel = t("settings.diagnostics.app.progress.desktop");
|
|
updateRunProgress("desktop", desktopLabel, "running");
|
|
const desktopResult = await collectDesktopDiagnosticSections();
|
|
sections.push(...desktopResult.sections);
|
|
updateRunProgress("desktop", desktopLabel, desktopResult.status);
|
|
}
|
|
|
|
const store = getHostRuntimeStore();
|
|
for (const host of hosts) {
|
|
const hostProgressId = `host:${host.serverId}`;
|
|
updateRunProgress(hostProgressId, host.label, "running");
|
|
const snapshot = store.getSnapshot(host.serverId);
|
|
const hostResult = await collectHostDiagnosticSections(host, snapshot);
|
|
sections.push(...hostResult.sections);
|
|
updateRunProgress(hostProgressId, host.label, hostResult.status);
|
|
}
|
|
|
|
if (isCurrentRun()) {
|
|
setDiagnostic(redactAppDiagnosticReport(sections.join("\n\n"), hosts));
|
|
}
|
|
} finally {
|
|
if (isCurrentRun()) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}, [appVersion, hosts, isDesktopApp, t, updateProgress]);
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
void runDiagnostics();
|
|
} else {
|
|
runIdRef.current += 1;
|
|
setLoading(false);
|
|
setDiagnostic(null);
|
|
setProgress([]);
|
|
}
|
|
}, [visible, runDiagnostics]);
|
|
|
|
const handleRefreshPress = useCallback(() => {
|
|
void runDiagnostics();
|
|
}, [runDiagnostics]);
|
|
|
|
const handleCopyPress = useCallback(() => {
|
|
if (!diagnostic) return;
|
|
void Clipboard.setStringAsync(diagnostic)
|
|
.then(() => toast.copied(t("settings.diagnostics.app.copyLabel")))
|
|
.catch(() => toast.error(t("settings.diagnostics.app.copyFailed")));
|
|
}, [diagnostic, t, toast]);
|
|
|
|
const iconButtonStyle = useCallback(
|
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
styles.iconButton,
|
|
(Boolean(hovered) || pressed) && styles.iconButtonHovered,
|
|
],
|
|
[],
|
|
);
|
|
|
|
const disabledIconButtonStyle = useCallback(
|
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
styles.iconButton,
|
|
(Boolean(hovered) || pressed) && Boolean(diagnostic) && styles.iconButtonHovered,
|
|
diagnostic ? null : styles.disabled,
|
|
],
|
|
[diagnostic],
|
|
);
|
|
|
|
const header = useMemo<SheetHeader>(
|
|
() => ({
|
|
title: t("settings.diagnostics.app.title"),
|
|
actions: (
|
|
<View style={styles.headerActions}>
|
|
<Pressable
|
|
onPress={handleCopyPress}
|
|
disabled={!diagnostic}
|
|
hitSlop={8}
|
|
style={disabledIconButtonStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("settings.diagnostics.app.copyAccessibility")}
|
|
>
|
|
<ThemedCopy size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={handleRefreshPress}
|
|
disabled={loading}
|
|
hitSlop={8}
|
|
style={iconButtonStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={
|
|
loading
|
|
? t("settings.diagnostics.app.refreshingAccessibility")
|
|
: t("settings.diagnostics.app.refreshAccessibility")
|
|
}
|
|
>
|
|
{loading ? (
|
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
|
) : (
|
|
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
),
|
|
}),
|
|
[
|
|
diagnostic,
|
|
disabledIconButtonStyle,
|
|
handleCopyPress,
|
|
handleRefreshPress,
|
|
iconButtonStyle,
|
|
loading,
|
|
t,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<AdaptiveModalSheet
|
|
header={header}
|
|
visible={visible}
|
|
onClose={onClose}
|
|
snapPoints={SNAP_POINTS}
|
|
scrollable={false}
|
|
testID="app-diagnostic-sheet"
|
|
>
|
|
<View style={DIAGNOSTIC_CARD_STYLE}>
|
|
{diagnostic ? (
|
|
<ScrollView style={styles.codeScroll} contentContainerStyle={styles.codeContent}>
|
|
<ScrollView horizontal showsHorizontalScrollIndicator>
|
|
<Text style={styles.codeText} selectable>
|
|
{diagnostic}
|
|
</Text>
|
|
</ScrollView>
|
|
</ScrollView>
|
|
) : (
|
|
<View style={styles.progressContent}>
|
|
{progress.length === 0 ? (
|
|
<View style={styles.progressRow}>
|
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
|
<Text style={styles.mutedText}>{t("settings.diagnostics.app.running")}</Text>
|
|
</View>
|
|
) : (
|
|
progress.map((row) => (
|
|
<View key={row.id} style={styles.progressRow}>
|
|
{row.status === "running" || row.status === "pending" ? (
|
|
<ThemedLoadingSpinner
|
|
size={ICON_SIZE.sm}
|
|
uniProps={foregroundMutedColorMapping}
|
|
/>
|
|
) : (
|
|
<View
|
|
style={row.status === "failed" ? FAILED_STATUS_DOT_STYLE : styles.statusDot}
|
|
/>
|
|
)}
|
|
<Text
|
|
style={styles.mutedText}
|
|
>{`${row.label}: ${formatProgressStatus(row.status)}`}</Text>
|
|
</View>
|
|
))
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
</AdaptiveModalSheet>
|
|
);
|
|
}
|
|
|
|
async function collectDesktopDiagnosticSections(): Promise<DiagnosticCollectionResult> {
|
|
try {
|
|
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
|
return {
|
|
status: "done",
|
|
sections: [
|
|
formatDiagnosticSection("Desktop", [
|
|
{ label: "Daemon status", value: status.status },
|
|
{ label: "Desktop managed", value: String(status.desktopManaged) },
|
|
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
|
|
{ label: "Daemon version", value: status.version ?? "unknown" },
|
|
{ label: "Daemon home", value: status.home || "unknown" },
|
|
{ label: "Log path", value: logs.logPath || "unknown" },
|
|
{ label: "Error", value: status.error ?? "none" },
|
|
]),
|
|
[
|
|
"Desktop daemon log tail",
|
|
logs.contents ? indentBlock(logs.contents) : " No log lines found",
|
|
].join("\n"),
|
|
],
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
status: "failed",
|
|
sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])],
|
|
};
|
|
}
|
|
}
|
|
|
|
async function collectHostDiagnosticSections(
|
|
host: HostProfile,
|
|
snapshot: HostRuntimeSnapshot | null,
|
|
): Promise<DiagnosticCollectionResult> {
|
|
const sections = [formatHostRuntimeSection({ host, snapshot })];
|
|
const client = snapshot?.client ?? null;
|
|
if (snapshot?.connectionStatus !== "online" || !client) {
|
|
sections.push(
|
|
formatDiagnosticSection(`Host diagnostics: ${host.label}`, [
|
|
{ label: "Status", value: "host is not connected" },
|
|
]),
|
|
);
|
|
return { sections, status: "done" };
|
|
}
|
|
|
|
try {
|
|
const serverInfo = client.getLastServerInfoMessage();
|
|
sections.push(formatServerInfoSection(serverInfo));
|
|
|
|
const rttMs = await client.measureLatency({ timeoutMs: 5000 });
|
|
sections.push(
|
|
formatDiagnosticSection(`Host latency: ${host.label}`, [
|
|
{ label: "Active RTT", value: `${Math.round(rttMs)}ms` },
|
|
]),
|
|
);
|
|
|
|
if (serverInfo?.features?.daemonDiagnostics === true) {
|
|
const result = await client.collectDiagnostics();
|
|
sections.push(result.diagnostic);
|
|
} else {
|
|
sections.push(
|
|
formatDiagnosticSection(`Daemon diagnostics: ${host.label}`, [
|
|
{ label: "Status", value: "unsupported by this daemon" },
|
|
]),
|
|
);
|
|
}
|
|
|
|
return { sections, status: "done" };
|
|
} catch (error) {
|
|
sections.push(
|
|
formatDiagnosticSection(`Host diagnostics: ${host.label}`, [
|
|
{ label: "Error", value: toMessage(error) },
|
|
]),
|
|
);
|
|
return { sections, status: "failed" };
|
|
}
|
|
}
|
|
|
|
function indentBlock(value: string): string {
|
|
return value
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => ` ${line}`)
|
|
.join("\n");
|
|
}
|
|
|
|
function toMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function formatProgressStatus(status: ProgressStatus): string {
|
|
switch (status) {
|
|
case "done":
|
|
return "done";
|
|
case "failed":
|
|
return "failed";
|
|
case "running":
|
|
case "pending":
|
|
return "running";
|
|
}
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
diagnosticCard: {
|
|
overflow: "hidden",
|
|
},
|
|
codeScroll: {
|
|
maxHeight: 520,
|
|
},
|
|
codeContent: {
|
|
paddingVertical: theme.spacing[3],
|
|
paddingHorizontal: theme.spacing[4],
|
|
},
|
|
codeText: {
|
|
fontFamily: theme.fontFamily.mono,
|
|
fontSize: theme.fontSize.code,
|
|
color: theme.colors.foreground,
|
|
lineHeight: 18,
|
|
},
|
|
progressContent: {
|
|
paddingVertical: theme.spacing[4],
|
|
paddingHorizontal: theme.spacing[4],
|
|
gap: theme.spacing[3],
|
|
},
|
|
progressRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
minHeight: 24,
|
|
},
|
|
mutedText: {
|
|
flex: 1,
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
headerActions: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
},
|
|
iconButton: {
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: theme.borderRadius.full,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
iconButtonHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
disabled: {
|
|
opacity: 0.5,
|
|
},
|
|
statusDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: theme.borderRadius.full,
|
|
backgroundColor: theme.colors.success,
|
|
},
|
|
statusDotFailed: {
|
|
backgroundColor: theme.colors.destructive,
|
|
},
|
|
}));
|
|
|
|
const DIAGNOSTIC_CARD_STYLE = [settingsStyles.card, styles.diagnosticCard];
|
|
const FAILED_STATUS_DOT_STYLE = [styles.statusDot, styles.statusDotFailed];
|