Merge pull request #108 from eqlabs/feat/debug-prettify

Debug stream improvements.
This commit is contained in:
muzamil
2022-03-08 21:07:13 +05:30
committed by GitHub
11 changed files with 246 additions and 61 deletions

View File

@@ -27,7 +27,7 @@ const labelStyle = css({
mb: "$0.5", mb: "$0.5",
}); });
const AccountDialog = ({ export const AccountDialog = ({
activeAccountAddress, activeAccountAddress,
setActiveAccountAddress, setActiveAccountAddress,
}: { }: {

View File

@@ -1,84 +1,136 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect } from "react";
import { useSnapshot } from "valtio"; import { proxy, ref, useSnapshot } from "valtio";
import { Select } from "."; import { Select } from ".";
import state from "../state"; import state, { ILog } from "../state";
import { extractJSON } from "../utils/json";
import LogBox from "./LogBox"; import LogBox from "./LogBox";
import Text from "./Text";
interface ISelect<T = string> {
label: string;
value: T;
}
const streamState = proxy({
selectedAccount: null as ISelect | null,
logs: [] as ILog[],
socket: undefined as WebSocket | undefined,
});
const DebugStream = () => { const DebugStream = () => {
const snap = useSnapshot(state); const { selectedAccount, logs, socket } = useSnapshot(streamState);
const { accounts } = useSnapshot(state);
const accountOptions = snap.accounts.map(acc => ({ const accountOptions = accounts.map(acc => ({
label: acc.name, label: acc.name,
value: acc.address, value: acc.address,
})); }));
const [selectedAccount, setSelectedAccount] = useState<typeof accountOptions[0] | null>(null);
const renderNav = () => ( const renderNav = () => (
<> <>
<Text css={{ mx: "$2", fontSize: "inherit" }}>Account: </Text>
<Select <Select
instanceId="debugStreamAccount" instanceId="DSAccount"
placeholder="Select account" placeholder="Select account"
options={accountOptions} options={accountOptions}
hideSelectedOptions hideSelectedOptions
value={selectedAccount} value={selectedAccount}
onChange={acc => setSelectedAccount(acc as any)} onChange={acc => (streamState.selectedAccount = acc as any)}
css={{ width: "30%" }} css={{ width: "100%" }}
/> />
</> </>
); );
const prepareLog = useCallback((str: any): ILog => {
if (typeof str !== "string") throw Error("Unrecognized debug log stream!");
const match = str.match(/([\s\S]+(?:UTC|ISO|GMT[+|-]\d+))\ ?([\s\S]*)/m);
const [_, tm, msg] = match || [];
const extracted = extractJSON(msg);
const timestamp = isNaN(Date.parse(tm || ""))
? tm
: new Date(tm).toLocaleTimeString();
const message = !extracted
? msg
: msg.slice(0, extracted.start) + msg.slice(extracted.end + 1);
const jsonData = extracted
? JSON.stringify(extracted.result, null, 2)
: undefined;
return {
type: "log",
message,
timestamp,
jsonData,
defaultCollapsed: true,
};
}, []);
useEffect(() => { useEffect(() => {
const account = selectedAccount?.value; const account = selectedAccount?.value;
if (!account) { if (account && (!socket || !socket.url.endsWith(account))) {
return; socket?.close();
streamState.socket = ref(
new WebSocket(
`wss://hooks-testnet-debugstream.xrpl-labs.com/${account}`
)
);
} else if (!account && socket) {
socket.close();
streamState.socket = undefined;
} }
const socket = new WebSocket(`wss://hooks-testnet-debugstream.xrpl-labs.com/${account}`); }, [selectedAccount?.value, socket]);
useEffect(() => {
const account = selectedAccount?.value;
const socket = streamState.socket;
if (!socket) return;
const onOpen = () => { const onOpen = () => {
state.debugLogs = []; streamState.logs = [];
state.debugLogs.push({ streamState.logs.push({
type: "success", type: "success",
message: `Debug stream opened for account ${account}`, message: `Debug stream opened for account ${account}`,
}); });
}; };
const onError = () => { const onError = () => {
state.debugLogs.push({ streamState.logs.push({
type: "error", type: "error",
message: "Something went wrong in establishing connection!", message: "Something went wrong! Check your connection and try again.",
}); });
setSelectedAccount(null); };
const onClose = (e: CloseEvent) => {
streamState.logs.push({
type: "error",
message: `Connection was closed. [code: ${e.code}]`,
});
streamState.selectedAccount = null;
}; };
const onMessage = (event: any) => { const onMessage = (event: any) => {
if (!event.data) return; if (!event.data) return;
state.debugLogs.push({ streamState.logs.push(prepareLog(event.data));
type: "log",
message: event.data,
});
}; };
socket.addEventListener("open", onOpen); socket.addEventListener("open", onOpen);
socket.addEventListener("close", onError); socket.addEventListener("close", onClose);
socket.addEventListener("error", onError); socket.addEventListener("error", onError);
socket.addEventListener("message", onMessage); socket.addEventListener("message", onMessage);
return () => { return () => {
socket.removeEventListener("open", onOpen); socket.removeEventListener("open", onOpen);
socket.removeEventListener("close", onError); socket.removeEventListener("close", onClose);
socket.removeEventListener("message", onMessage); socket.removeEventListener("message", onMessage);
socket.removeEventListener("error", onError);
socket.close();
}; };
}, [selectedAccount]); }, [prepareLog, selectedAccount?.value, socket]);
return ( return (
<LogBox <LogBox
enhanced enhanced
renderNav={renderNav} renderNav={renderNav}
title="Debug stream" title="Debug stream"
logs={snap.debugLogs} logs={logs}
clearLog={() => (state.debugLogs = [])} clearLog={() => (streamState.logs = [])}
/> />
); );
}; };

View File

@@ -3,6 +3,14 @@ import { styled } from "../stitches.config";
const StyledLink = styled("a", { const StyledLink = styled("a", {
color: "CurrentColor", color: "CurrentColor",
textDecoration: "underline", textDecoration: "underline",
cursor: 'pointer',
variants: {
highlighted: {
true: {
color: '$blue9'
}
}
}
}); });
export default StyledLink; export default StyledLink;

View File

@@ -1,17 +1,15 @@
import React, { useRef, useLayoutEffect, ReactNode } from "react"; import { useRef, useLayoutEffect, ReactNode, FC, useState, useCallback } from "react";
import { Notepad, Prohibit } from "phosphor-react"; import { Notepad, Prohibit } from "phosphor-react";
import useStayScrolled from "react-stay-scrolled"; import useStayScrolled from "react-stay-scrolled";
import NextLink from "next/link"; import NextLink from "next/link";
import Container from "./Container"; import Container from "./Container";
import Box from "./Box";
import Flex from "./Flex";
import LogText from "./LogText"; import LogText from "./LogText";
import { ILog } from "../state"; import state, { ILog } from "../state";
import Text from "./Text"; import { Pre, Link, Heading, Button, Text, Flex, Box } from ".";
import Button from "./Button"; import regexifyString from "regexify-string";
import Heading from "./Heading"; import { useSnapshot } from "valtio";
import Link from "./Link"; import { AccountDialog } from "./Accounts";
interface ILogBox { interface ILogBox {
title: string; title: string;
@@ -21,14 +19,7 @@ interface ILogBox {
enhanced?: boolean; enhanced?: boolean;
} }
const LogBox: React.FC<ILogBox> = ({ const LogBox: FC<ILogBox> = ({ title, clearLog, logs, children, renderNav, enhanced }) => {
title,
clearLog,
logs,
children,
renderNav,
enhanced,
}) => {
const logRef = useRef<HTMLPreElement>(null); const logRef = useRef<HTMLPreElement>(null);
const { stayScrolled /*, scrollBottom*/ } = useStayScrolled(logRef); const { stayScrolled /*, scrollBottom*/ } = useStayScrolled(logRef);
@@ -55,6 +46,7 @@ const LogBox: React.FC<ILogBox> = ({
}} }}
> >
<Flex <Flex
fluid
css={{ css={{
height: "48px", height: "48px",
alignItems: "center", alignItems: "center",
@@ -78,7 +70,15 @@ const LogBox: React.FC<ILogBox> = ({
> >
<Notepad size="15px" /> <Text css={{ lineHeight: 1 }}>{title}</Text> <Notepad size="15px" /> <Text css={{ lineHeight: 1 }}>{title}</Text>
</Heading> </Heading>
{renderNav?.()} <Flex
row
align="center"
css={{
width: "50%", // TODO make it max without breaking layout!
}}
>
{renderNav?.()}
</Flex>
<Flex css={{ ml: "auto", gap: "$3", marginRight: "$3" }}> <Flex css={{ ml: "auto", gap: "$3", marginRight: "$3" }}>
{clearLog && ( {clearLog && (
<Button ghost size="xs" onClick={clearLog}> <Button ghost size="xs" onClick={clearLog}>
@@ -117,17 +117,11 @@ const LogBox: React.FC<ILogBox> = ({
backgroundColor: enhanced ? "$backgroundAlt" : undefined, backgroundColor: enhanced ? "$backgroundAlt" : undefined,
}, },
}, },
p: enhanced ? "$2 $1" : undefined, p: enhanced ? "$1" : undefined,
my: enhanced ? "$1" : undefined,
}} }}
> >
<LogText variant={log.type}> <Log {...log} />
{log.message}{" "}
{log.link && (
<NextLink href={log.link} shallow passHref>
<Link as="a">{log.linkText}</Link>
</NextLink>
)}
</LogText>
</Box> </Box>
))} ))}
{children} {children}
@@ -137,4 +131,74 @@ const LogBox: React.FC<ILogBox> = ({
); );
}; };
export const Log: FC<ILog> = ({
type,
timestamp: timestamp,
message: _message,
link,
linkText,
defaultCollapsed,
jsonData: _jsonData,
}) => {
const [expanded, setExpanded] = useState(!defaultCollapsed);
const { accounts } = useSnapshot(state);
const [dialogAccount, setDialogAccount] = useState<string | null>(null);
const enrichAccounts = useCallback(
(str?: string): ReactNode => {
if (!str || !accounts.length) return null;
const pattern = `(${accounts.map(acc => acc.address).join("|")})`;
const res = regexifyString({
pattern: new RegExp(pattern, "gim"),
decorator: (match, idx) => {
const name = accounts.find(acc => acc.address === match)?.name;
return (
<Link
key={match + idx}
as="a"
onClick={() => setDialogAccount(match)}
title={match}
highlighted
>
{name || match}
</Link>
);
},
input: str,
});
return <>{res}</>;
},
[accounts]
);
_message = _message.trim().replace(/\n /gi, "\n");
const message = enrichAccounts(_message);
const jsonData = enrichAccounts(_jsonData);
return (
<>
<AccountDialog
setActiveAccountAddress={setDialogAccount}
activeAccountAddress={dialogAccount}
/>
<LogText variant={type}>
{timestamp && <Text muted monospace>{timestamp} </Text>}
<Pre>{message} </Pre>
{link && (
<NextLink href={link} shallow passHref>
<Link as="a">{linkText}</Link>
</NextLink>
)}
{jsonData && (
<Link onClick={() => setExpanded(!expanded)} as="a">
{expanded ? "Collapse" : "Expand"}
</Link>
)}
{expanded && jsonData && <Pre block>{jsonData}</Pre>}
</LogText>
</>
);
};
export default LogBox; export default LogBox;

27
components/Pre.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { styled } from "../stitches.config";
const Pre = styled("span", {
m: 0,
wordBreak: "break-all",
fontFamily: '$monospace',
whiteSpace: 'pre-wrap',
variants: {
fluid: {
true: {
width: "100%",
},
},
line: {
true: {
whiteSpace: 'pre-line'
}
},
block: {
true: {
display: 'block'
}
}
},
});
export default Pre;

View File

@@ -14,6 +14,11 @@ const Text = styled("span", {
true: { true: {
color: '$mauve9' color: '$mauve9'
} }
},
monospace: {
true: {
fontFamily: '$monospace'
}
} }
} }
}); });

View File

@@ -10,6 +10,7 @@ export * from "./Tabs";
export * from "./AlertDialog"; export * from "./AlertDialog";
export { default as Box } from "./Box"; export { default as Box } from "./Box";
export { default as Button } from "./Button"; export { default as Button } from "./Button";
export { default as Pre } from "./Pre";
export { default as ButtonGroup } from "./ButtonGroup"; export { default as ButtonGroup } from "./ButtonGroup";
export { default as DeployFooter } from "./DeployFooter"; export { default as DeployFooter } from "./DeployFooter";
export * from "./Dialog"; export * from "./Dialog";

View File

@@ -47,6 +47,7 @@
"react-stay-scrolled": "^7.4.0", "react-stay-scrolled": "^7.4.0",
"react-time-ago": "^7.1.9", "react-time-ago": "^7.1.9",
"reconnecting-websocket": "^4.4.0", "reconnecting-websocket": "^4.4.0",
"regexify-string": "^1.0.17",
"valtio": "^1.2.5", "valtio": "^1.2.5",
"vscode-languageserver": "^7.0.0", "vscode-languageserver": "^7.0.0",
"vscode-uri": "^3.0.2", "vscode-uri": "^3.0.2",

View File

@@ -34,8 +34,11 @@ export interface IAccount {
export interface ILog { export interface ILog {
type: "error" | "warning" | "log" | "success"; type: "error" | "warning" | "log" | "success";
message: string; message: string;
jsonData?: any,
timestamp?: string;
link?: string; link?: string;
linkText?: string; linkText?: string;
defaultCollapsed?: boolean
} }
export interface IState { export interface IState {
@@ -52,7 +55,6 @@ export interface IState {
logs: ILog[]; logs: ILog[];
deployLogs: ILog[]; deployLogs: ILog[];
transactionLogs: ILog[]; transactionLogs: ILog[];
debugLogs: ILog[];
editorCtx?: typeof monaco.editor; editorCtx?: typeof monaco.editor;
editorSettings: { editorSettings: {
tabSize: number; tabSize: number;
@@ -78,7 +80,6 @@ let initialState: IState = {
logs: [], logs: [],
deployLogs: [], deployLogs: [],
transactionLogs: [], transactionLogs: [],
debugLogs: [],
editorCtx: undefined, editorCtx: undefined,
gistId: undefined, gistId: undefined,
gistOwner: undefined, gistOwner: undefined,

21
utils/json.ts Normal file
View File

@@ -0,0 +1,21 @@
export const extractJSON = (str?: string) => {
if (!str) return
let firstOpen = 0, firstClose = 0, candidate = '';
firstOpen = str.indexOf('{', firstOpen + 1);
do {
firstClose = str.lastIndexOf('}');
if (firstClose <= firstOpen) {
return;
}
do {
candidate = str.substring(firstOpen, firstClose + 1);
try {
let result = JSON.parse(candidate);
return { result, start: firstOpen < 0 ? 0 : firstOpen, end: firstClose }
}
catch (e) { }
firstClose = str.substring(0, firstClose).lastIndexOf('}');
} while (firstClose > firstOpen);
firstOpen = str.indexOf('{', firstOpen + 1);
} while (firstOpen != -1);
}

View File

@@ -3989,6 +3989,11 @@ regenerator-runtime@^0.13.4:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
regexify-string@^1.0.17:
version "1.0.17"
resolved "https://registry.yarnpkg.com/regexify-string/-/regexify-string-1.0.17.tgz#b9e571b51c8ec566eb82b7121744dae0d8e829de"
integrity sha512-mmD0AUNaY/piGW2AyACWdQOjIAwNuWz+KIvxfBZPDdCBAexiROeQxdxTaYAWcIxwtUAOwojdTta6CMMil84jXw==
regexp.prototype.flags@^1.3.1: regexp.prototype.flags@^1.3.1:
version "1.3.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz#7ef352ae8d159e758c0eadca6f8fcb4eef07be26" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz#7ef352ae8d159e758c0eadca6f8fcb4eef07be26"