Add app diagnostic report (#1728)
* 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
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
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];
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { formatHostRuntimeSection, redactAppDiagnosticReport } from "./app-diagnostic-report";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
|
||||
function makeHost(): HostProfile {
|
||||
return {
|
||||
serverId: "srv-secret",
|
||||
label: "Secret host",
|
||||
lifecycle: {},
|
||||
preferredConnectionId: "direct:secret.example.test:6767",
|
||||
createdAt: "2026-06-25T00:00:00.000Z",
|
||||
updatedAt: "2026-06-25T00:00:00.000Z",
|
||||
connections: [
|
||||
{
|
||||
id: "direct:secret.example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "secret.example.test:6767",
|
||||
useTls: true,
|
||||
password: "tcp-password",
|
||||
},
|
||||
{
|
||||
id: "relay:relay.secret.test:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.secret.test:443",
|
||||
useTls: true,
|
||||
daemonPublicKeyB64: "daemon-public-key-secret",
|
||||
},
|
||||
{
|
||||
id: "socket:/tmp/paseo-secret.sock",
|
||||
type: "directSocket",
|
||||
path: "/tmp/paseo-secret.sock",
|
||||
},
|
||||
{
|
||||
id: "pipe:\\\\.\\pipe\\paseo-secret",
|
||||
type: "directPipe",
|
||||
path: "\\\\.\\pipe\\paseo-secret",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("app diagnostics report", () => {
|
||||
test("formats connection rows without raw connection details", () => {
|
||||
const host = makeHost();
|
||||
const snapshot: HostRuntimeSnapshot = {
|
||||
serverId: host.serverId,
|
||||
activeConnectionId: "relay:relay.secret.test:443",
|
||||
activeConnection: {
|
||||
type: "relay",
|
||||
endpoint: "relay.secret.test:443",
|
||||
display: "relay",
|
||||
},
|
||||
connectionStatus: "online",
|
||||
client: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: "2026-06-25T00:00:00.000Z",
|
||||
agentDirectoryStatus: "ready",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
probeByConnectionId: new Map([
|
||||
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
|
||||
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
|
||||
]),
|
||||
clientGeneration: 1,
|
||||
};
|
||||
|
||||
const report = formatHostRuntimeSection({ host, snapshot });
|
||||
|
||||
expect(report).toContain("direct TCP");
|
||||
expect(report).toContain("relay");
|
||||
expect(report).toContain("local socket");
|
||||
expect(report).toContain("local pipe");
|
||||
expect(report).not.toContain("secret.example.test");
|
||||
expect(report).not.toContain("relay.secret.test");
|
||||
expect(report).not.toContain("daemon-public-key-secret");
|
||||
expect(report).not.toContain("/tmp/paseo-secret.sock");
|
||||
expect(report).not.toContain("tcp-password");
|
||||
});
|
||||
|
||||
test("redacts saved connection secrets from collected daemon and desktop text", () => {
|
||||
const host = makeHost();
|
||||
const redacted = redactAppDiagnosticReport(
|
||||
[
|
||||
"secret.example.test:6767",
|
||||
"relay.secret.test:443",
|
||||
"daemon-public-key-secret",
|
||||
"/tmp/paseo-secret.sock",
|
||||
"\\\\.\\pipe\\paseo-secret",
|
||||
"password=tcp-password",
|
||||
"paseo://pairing-secret",
|
||||
].join("\n"),
|
||||
[host],
|
||||
);
|
||||
|
||||
expect(redacted).not.toContain("secret.example.test");
|
||||
expect(redacted).not.toContain("relay.secret.test");
|
||||
expect(redacted).not.toContain("daemon-public-key-secret");
|
||||
expect(redacted).not.toContain("/tmp/paseo-secret.sock");
|
||||
expect(redacted).not.toContain("\\\\.\\pipe\\paseo-secret");
|
||||
expect(redacted).not.toContain("tcp-password");
|
||||
expect(redacted).not.toContain("pairing-secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
|
||||
interface DiagnosticEntry {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function formatDiagnosticSection(title: string, entries: DiagnosticEntry[]): string {
|
||||
return [title, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n");
|
||||
}
|
||||
|
||||
export function formatAppDiagnosticHeader(input: {
|
||||
appVersion: string | null;
|
||||
platform: string;
|
||||
isDesktopApp: boolean;
|
||||
hostCount: number;
|
||||
}): string {
|
||||
return formatDiagnosticSection("Paseo app diagnostics", [
|
||||
{ label: "Collected at", value: new Date().toISOString() },
|
||||
{ label: "App version", value: input.appVersion ?? "unknown" },
|
||||
{ label: "Platform", value: input.platform },
|
||||
{ label: "Desktop app", value: String(input.isDesktopApp) },
|
||||
{ label: "Saved hosts", value: String(input.hostCount) },
|
||||
]);
|
||||
}
|
||||
|
||||
export function formatHostRuntimeSection(input: {
|
||||
host: HostProfile;
|
||||
snapshot: HostRuntimeSnapshot | null;
|
||||
}): string {
|
||||
const { host, snapshot } = input;
|
||||
const entries: DiagnosticEntry[] = [
|
||||
{ label: "Server ID", value: host.serverId },
|
||||
{ label: "Status", value: snapshot?.connectionStatus ?? "not started" },
|
||||
{
|
||||
label: "Active connection",
|
||||
value: snapshot?.activeConnection
|
||||
? describeConnectionKind(snapshot.activeConnection.type)
|
||||
: "none",
|
||||
},
|
||||
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "never" },
|
||||
{ label: "Last error", value: snapshot?.lastError ?? "none" },
|
||||
{
|
||||
label: "Agent directory",
|
||||
value: snapshot?.agentDirectoryStatus ?? "unknown",
|
||||
},
|
||||
];
|
||||
|
||||
const connectionRows = host.connections.map((connection, index) => {
|
||||
const probe = snapshot?.probeByConnectionId.get(connection.id) ?? null;
|
||||
const isActive = snapshot?.activeConnectionId === connection.id;
|
||||
return {
|
||||
label: `Connection ${index + 1}`,
|
||||
value: [
|
||||
describeConnectionKind(connection.type),
|
||||
isActive ? "active" : "inactive",
|
||||
probe ? `probe=${probe.status}` : "probe=unknown",
|
||||
probe?.status === "available" ? `latency=${Math.round(probe.latencyMs)}ms` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
};
|
||||
});
|
||||
|
||||
return formatDiagnosticSection(`Host: ${host.label}`, [...entries, ...connectionRows]);
|
||||
}
|
||||
|
||||
export function formatServerInfoSection(serverInfo: ServerInfoStatusPayload | null): string {
|
||||
if (!serverInfo) {
|
||||
return formatDiagnosticSection("Server info", [{ label: "Status", value: "not received" }]);
|
||||
}
|
||||
|
||||
const features = serverInfo.features ? Object.keys(serverInfo.features).sort() : [];
|
||||
return formatDiagnosticSection("Server info", [
|
||||
{ label: "Server ID", value: serverInfo.serverId },
|
||||
{ label: "Hostname", value: serverInfo.hostname ?? "unknown" },
|
||||
{ label: "Version", value: serverInfo.version ?? "unknown" },
|
||||
{ label: "Features", value: features.length > 0 ? features.join(", ") : "none" },
|
||||
]);
|
||||
}
|
||||
|
||||
export function describeConnectionKind(type: HostConnection["type"] | string): string {
|
||||
switch (type) {
|
||||
case "directTcp":
|
||||
return "direct TCP";
|
||||
case "directSocket":
|
||||
return "local socket";
|
||||
case "directPipe":
|
||||
return "local pipe";
|
||||
case "relay":
|
||||
return "relay";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function redactAppDiagnosticReport(report: string, hosts: HostProfile[]): string {
|
||||
let redacted = report;
|
||||
for (const value of collectSensitiveHostValues(hosts)) {
|
||||
redacted = redacted.split(value).join("[redacted]");
|
||||
}
|
||||
return redacted
|
||||
.replace(/paseo:\/\/\S+/gi, "paseo://[redacted]")
|
||||
.replace(
|
||||
/([?&](?:password|token|secret|key|publicKey|daemonPublicKeyB64)=)[^&\s"']+/gi,
|
||||
"$1[redacted]",
|
||||
)
|
||||
.replace(
|
||||
/((?:password|token|secret|authorization|api[_-]?key|daemonPublicKeyB64|relayKey)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s,}]+)/gi,
|
||||
"$1[redacted]",
|
||||
);
|
||||
}
|
||||
|
||||
function collectSensitiveHostValues(hosts: HostProfile[]): string[] {
|
||||
const values = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
for (const connection of host.connections) {
|
||||
values.add(connection.id);
|
||||
if (connection.type === "directTcp") {
|
||||
values.add(connection.endpoint);
|
||||
if (connection.password) values.add(connection.password);
|
||||
} else if (connection.type === "relay") {
|
||||
values.add(connection.relayEndpoint);
|
||||
values.add(connection.daemonPublicKeyB64);
|
||||
} else if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
values.add(connection.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...values].filter((value) => value.trim().length > 0);
|
||||
}
|
||||
@@ -1451,6 +1451,22 @@ export const ar: TranslationResources = {
|
||||
playTest: "لعب الاختبار",
|
||||
playing: "جارٍ اللعب...",
|
||||
playbackFailed: "فشل التشغيل:{{message}}",
|
||||
app: {
|
||||
title: "App diagnostic",
|
||||
rowTitle: "App diagnostic",
|
||||
rowHint: "Collect connection, daemon, provider, desktop, and log details",
|
||||
run: "Run",
|
||||
running: "Running diagnostic...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
progress: {
|
||||
client: "Client",
|
||||
desktop: "Desktop",
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "عن",
|
||||
|
||||
@@ -1457,6 +1457,22 @@ export const en = {
|
||||
playTest: "Play test",
|
||||
playing: "Playing...",
|
||||
playbackFailed: "Playback failed: {{message}}",
|
||||
app: {
|
||||
title: "App diagnostic",
|
||||
rowTitle: "App diagnostic",
|
||||
rowHint: "Collect connection, daemon, provider, desktop, and log details",
|
||||
run: "Run",
|
||||
running: "Running diagnostic...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
progress: {
|
||||
client: "Client",
|
||||
desktop: "Desktop",
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "About",
|
||||
|
||||
@@ -1488,6 +1488,22 @@ export const es: TranslationResources = {
|
||||
playTest: "Prueba de juego",
|
||||
playing: "Jugando...",
|
||||
playbackFailed: "Error de reproducción:{{message}}",
|
||||
app: {
|
||||
title: "App diagnostic",
|
||||
rowTitle: "App diagnostic",
|
||||
rowHint: "Collect connection, daemon, provider, desktop, and log details",
|
||||
run: "Run",
|
||||
running: "Running diagnostic...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
progress: {
|
||||
client: "Client",
|
||||
desktop: "Desktop",
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "Acerca de",
|
||||
|
||||
@@ -1491,6 +1491,22 @@ export const fr: TranslationResources = {
|
||||
playTest: "Jouer à l'essai",
|
||||
playing: "Jouant...",
|
||||
playbackFailed: "Échec de la lecture:{{message}}",
|
||||
app: {
|
||||
title: "App diagnostic",
|
||||
rowTitle: "App diagnostic",
|
||||
rowHint: "Collect connection, daemon, provider, desktop, and log details",
|
||||
run: "Run",
|
||||
running: "Running diagnostic...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
progress: {
|
||||
client: "Client",
|
||||
desktop: "Desktop",
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "À propos",
|
||||
|
||||
@@ -1466,6 +1466,22 @@ export const ja: TranslationResources = {
|
||||
playTest: "テスト再生",
|
||||
playing: "再生中...",
|
||||
playbackFailed: "再生に失敗しました: {{message}}",
|
||||
app: {
|
||||
title: "App diagnostic",
|
||||
rowTitle: "App diagnostic",
|
||||
rowHint: "Collect connection, daemon, provider, desktop, and log details",
|
||||
run: "Run",
|
||||
running: "Running diagnostic...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
progress: {
|
||||
client: "Client",
|
||||
desktop: "Desktop",
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "アプリ情報",
|
||||
|
||||
@@ -1473,6 +1473,22 @@ export const ptBR: TranslationResources = {
|
||||
playTest: "Reproduzir teste",
|
||||
playing: "Reproduzindo...",
|
||||
playbackFailed: "Falha na reprodução: {{message}}",
|
||||
app: {
|
||||
title: "App diagnostic",
|
||||
rowTitle: "App diagnostic",
|
||||
rowHint: "Collect connection, daemon, provider, desktop, and log details",
|
||||
run: "Run",
|
||||
running: "Running diagnostic...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
progress: {
|
||||
client: "Client",
|
||||
desktop: "Desktop",
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "Sobre",
|
||||
|
||||
@@ -1478,6 +1478,22 @@ export const ru: TranslationResources = {
|
||||
playTest: "Игровой тест",
|
||||
playing: "Игра...",
|
||||
playbackFailed: "Ошибка воспроизведения:{{message}}",
|
||||
app: {
|
||||
title: "App diagnostic",
|
||||
rowTitle: "App diagnostic",
|
||||
rowHint: "Collect connection, daemon, provider, desktop, and log details",
|
||||
run: "Run",
|
||||
running: "Running diagnostic...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
progress: {
|
||||
client: "Client",
|
||||
desktop: "Desktop",
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "О",
|
||||
|
||||
@@ -1433,6 +1433,22 @@ export const zhCN: TranslationResources = {
|
||||
playTest: "播放测试",
|
||||
playing: "正在播放...",
|
||||
playbackFailed: "播放失败:{{message}}",
|
||||
app: {
|
||||
title: "App diagnostic",
|
||||
rowTitle: "App diagnostic",
|
||||
rowHint: "Collect connection, daemon, provider, desktop, and log details",
|
||||
run: "Run",
|
||||
running: "Running diagnostic...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
progress: {
|
||||
client: "Client",
|
||||
desktop: "Desktop",
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "关于",
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
SquareTerminal,
|
||||
} from "lucide-react-native";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { AppDiagnosticSheet } from "@/components/app-diagnostic-sheet";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
@@ -455,6 +456,8 @@ interface DiagnosticsSectionProps {
|
||||
isPlaybackTestRunning: boolean;
|
||||
playbackTestResult: string | null;
|
||||
handlePlaybackTest: () => Promise<void>;
|
||||
appVersion: string | null;
|
||||
isDesktopApp: boolean;
|
||||
}
|
||||
|
||||
function DiagnosticsSection({
|
||||
@@ -462,14 +465,28 @@ function DiagnosticsSection({
|
||||
isPlaybackTestRunning,
|
||||
playbackTestResult,
|
||||
handlePlaybackTest,
|
||||
appVersion,
|
||||
isDesktopApp,
|
||||
}: DiagnosticsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [diagnosticSheetOpen, setDiagnosticSheetOpen] = useState(false);
|
||||
const handlePlayPress = useCallback(() => {
|
||||
void handlePlaybackTest();
|
||||
}, [handlePlaybackTest]);
|
||||
const handleOpenDiagnostic = useCallback(() => setDiagnosticSheetOpen(true), []);
|
||||
const handleCloseDiagnostic = useCallback(() => setDiagnosticSheetOpen(false), []);
|
||||
return (
|
||||
<SettingsSection title={t("settings.diagnostics.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row} testID="app-diagnostic-row">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.app.rowTitle")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.diagnostics.app.rowHint")}</Text>
|
||||
</View>
|
||||
<Button variant="secondary" size="sm" onPress={handleOpenDiagnostic}>
|
||||
{t("settings.diagnostics.app.run")}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.testAudio")}</Text>
|
||||
@@ -489,6 +506,12 @@ function DiagnosticsSection({
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<AppDiagnosticSheet
|
||||
visible={diagnosticSheetOpen}
|
||||
onClose={handleCloseDiagnostic}
|
||||
appVersion={appVersion}
|
||||
isDesktopApp={isDesktopApp}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1533,6 +1556,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
isPlaybackTestRunning={isPlaybackTestRunning}
|
||||
playbackTestResult={playbackTestResult}
|
||||
handlePlaybackTest={handlePlaybackTest}
|
||||
appVersion={appVersion}
|
||||
isDesktopApp={isDesktopApp}
|
||||
/>
|
||||
);
|
||||
case "about":
|
||||
|
||||
Reference in New Issue
Block a user