diff --git a/.gitignore b/.gitignore index 2b6318efd..99e372a31 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ yarn-error.log # Personal files start-dev.sh slam-geese.sh + +# Ignore .dev.vars files in the examples folder +examples/**/*.dev.vars diff --git a/studio/src/components/CodeMirrorEditor/CodeMirrorInput.tsx b/studio/src/components/CodeMirrorEditor/CodeMirrorInput.tsx index 42e3a5158..d47a289bd 100644 --- a/studio/src/components/CodeMirrorEditor/CodeMirrorInput.tsx +++ b/studio/src/components/CodeMirrorEditor/CodeMirrorInput.tsx @@ -3,6 +3,7 @@ import "./CodeMirrorEditorCssOverrides.css"; import { cn } from "@/utils"; import CodeMirror, { EditorView, gutter, keymap } from "@uiw/react-codemirror"; import { useMemo, useState } from "react"; +import { createOnSubmitKeymap, escapeKeymap } from "./keymaps"; const inputTheme = EditorView.theme({ "&": { @@ -113,30 +114,6 @@ const preventNewlineInFirefox = keymap.of([ }, ]); -// Extension that blurs the editor when the user presses "Escape" -const escapeKeymap = keymap.of([ - { - key: "Escape", - run: (view) => { - view.contentDOM.blur(); - return true; - }, - }, -]); - -const submitKeymap = (onSubmit: (() => void) | undefined) => - keymap.of([ - { - key: "Mod-Enter", - run: (_view) => { - if (onSubmit) { - onSubmit(); - } - return true; - }, - }, - ]); - export function CodeMirrorInput(props: CodeMirrorInputProps) { const { value, onChange, placeholder, className, readOnly, onSubmit } = props; @@ -163,7 +140,7 @@ export function CodeMirrorInput(props: CodeMirrorInputProps) { EditorView.lineWrapping, readOnly ? readonlyExtension : noopExtension, isFocused ? noopExtension : inputTrucateExtension, - submitKeymap(onSubmit), + createOnSubmitKeymap(onSubmit), ]; }, [isFocused, readOnly, onSubmit]); diff --git a/studio/src/components/CodeMirrorEditor/CodeMirrorJsonEditor.tsx b/studio/src/components/CodeMirrorEditor/CodeMirrorJsonEditor.tsx index d0bd351a2..f7622f653 100644 --- a/studio/src/components/CodeMirrorEditor/CodeMirrorJsonEditor.tsx +++ b/studio/src/components/CodeMirrorEditor/CodeMirrorJsonEditor.tsx @@ -2,8 +2,9 @@ import "./CodeMirrorEditorCssOverrides.css"; import { json } from "@codemirror/lang-json"; import { duotoneDark } from "@uiw/codemirror-theme-duotone"; - -import CodeMirror, { EditorView } from "@uiw/react-codemirror"; +import CodeMirror, { basicSetup, EditorView } from "@uiw/react-codemirror"; +import { useMemo } from "react"; +import { createOnSubmitKeymap, escapeKeymap } from "./keymaps"; import { customTheme } from "./themes"; type CodeMirrorEditorProps = { @@ -14,6 +15,7 @@ type CodeMirrorEditorProps = { readOnly?: boolean; value?: string; onChange: (value?: string) => void; + onSubmit?: () => void; }; export function CodeMirrorJsonEditor(props: CodeMirrorEditorProps) { @@ -24,24 +26,36 @@ export function CodeMirrorJsonEditor(props: CodeMirrorEditorProps) { readOnly, minHeight = "200px", maxHeight, + onSubmit, } = props; + const extensions = useMemo( + () => [ + createOnSubmitKeymap(onSubmit, false), + basicSetup({ + // Turn off searching the input via cmd+g and cmd+f + searchKeymap: false, + }), + EditorView.lineWrapping, + json(), + escapeKeymap, + ], + [onSubmit], + ); + return ( ); } diff --git a/studio/src/components/CodeMirrorEditor/CodeMirrorSqlEditor.tsx b/studio/src/components/CodeMirrorEditor/CodeMirrorSqlEditor.tsx index 5cf7bb1fb..cf58d3de6 100644 --- a/studio/src/components/CodeMirrorEditor/CodeMirrorSqlEditor.tsx +++ b/studio/src/components/CodeMirrorEditor/CodeMirrorSqlEditor.tsx @@ -7,10 +7,24 @@ import CodeMirror, { EditorView } from "@uiw/react-codemirror"; import { customTheme } from "./themes"; import type { CodeMirrorEditorProps } from "./types"; -type CodeMirrorSqlEditorProps = CodeMirrorEditorProps; +type CodeMirrorSqlEditorProps = CodeMirrorEditorProps & { + /** + * Whether to show line numbers in the editor + * @default true + */ + lineNumbers?: boolean; +}; export function CodeMirrorSqlEditor(props: CodeMirrorSqlEditorProps) { - const { height, value, onChange, minHeight, maxHeight, readOnly } = props; + const { + height, + value, + onChange, + minHeight, + maxHeight, + readOnly, + lineNumbers, + } = props; return ( ); } diff --git a/studio/src/components/CodeMirrorEditor/keymaps.ts b/studio/src/components/CodeMirrorEditor/keymaps.ts new file mode 100644 index 000000000..7391c1f42 --- /dev/null +++ b/studio/src/components/CodeMirrorEditor/keymaps.ts @@ -0,0 +1,36 @@ +import { keymap } from "@uiw/react-codemirror"; + +// Extension that blurs the editor when the user presses "Escape" +export const escapeKeymap = keymap.of([ + { + key: "Escape", + run: (view) => { + view.contentDOM.blur(); + return true; + }, + }, +]); + +/** + * Creates a keymap that calls the given (optional) onSubmit function when the user presses "Mod-Enter" + * + * @param onSubmit - Function to call when the user presses "Mod-Enter" + * @param bubbleWhenNoHandler - If there is no onSubmit function, let another extension handle the key event + * @returns - Keymap that calls the onSubmit function when the user presses "Mod-Enter" + */ +export const createOnSubmitKeymap = ( + onSubmit: (() => void) | undefined, + bubbleWhenNoHandler = true, +) => + keymap.of([ + { + key: "Mod-Enter", + run: () => { + if (onSubmit) { + onSubmit(); + return true; + } + return bubbleWhenNoHandler; + }, + }, + ]); diff --git a/studio/src/components/Timeline/DetailsList/index.ts b/studio/src/components/Timeline/DetailsList/index.ts index d3f4b1dd7..0e0820f4f 100644 --- a/studio/src/components/Timeline/DetailsList/index.ts +++ b/studio/src/components/Timeline/DetailsList/index.ts @@ -1,3 +1,4 @@ export { CollapsibleKeyValueTableV2 } from "./KeyValueTableV2"; export { TimelineListDetails } from "./TimelineDetailsList"; export { BodyViewerV2 } from "./BodyViewerV2"; +export { useFormattedNeonQuery } from "./spans"; diff --git a/studio/src/components/Timeline/DetailsList/spans/FetchSpan.tsx b/studio/src/components/Timeline/DetailsList/spans/FetchSpan/FetchSpan.tsx similarity index 86% rename from studio/src/components/Timeline/DetailsList/spans/FetchSpan.tsx rename to studio/src/components/Timeline/DetailsList/spans/FetchSpan/FetchSpan.tsx index 672ad187c..7baf2d987 100644 --- a/studio/src/components/Timeline/DetailsList/spans/FetchSpan.tsx +++ b/studio/src/components/Timeline/DetailsList/spans/FetchSpan/FetchSpan.tsx @@ -20,12 +20,12 @@ import { import type { OtelSpan } from "@fiberplane/fpx-types"; import { ClockIcon } from "@radix-ui/react-icons"; import { useMemo } from "react"; -import { format } from "sql-formatter"; -import { useTimelineIcon } from "../../hooks"; -import { CollapsibleSubSection, SectionHeading } from "../../shared"; -import { SubSection, SubSectionHeading } from "../../shared"; -import { CollapsibleKeyValueTableV2 } from "../KeyValueTableV2"; -import { TextOrJsonViewer } from "../TextJsonViewer"; +import { useTimelineIcon } from "../../../hooks"; +import { CollapsibleSubSection, SectionHeading } from "../../../shared"; +import { SubSection, SubSectionHeading } from "../../../shared"; +import { CollapsibleKeyValueTableV2 } from "../../KeyValueTableV2"; +import { TextOrJsonViewer } from "../../TextJsonViewer"; +import { useFormattedNeonQuery } from "./hooks"; export function FetchSpan({ span, @@ -226,21 +226,7 @@ function useVendorSpecificSection(vendorInfo: VendorInfo) { } function NeonSection({ vendorInfo }: { vendorInfo: NeonVendorInfo }) { - const queryValue = useMemo(() => { - try { - const paramsFromNeon = vendorInfo.sql.params ?? []; - // NOTE - sql-formatter expects the index in the array to match the `$nr` syntax from postgres - // this makes the 0th index unused, but it makes the rest of the indices match the `$1`, `$2`, etc. - const params = ["", ...paramsFromNeon]; - return format(vendorInfo.sql.query, { - language: "postgresql", - params, - }); - } catch (e) { - // Being very defensive soz - return vendorInfo?.sql?.query ?? ""; - } - }, [vendorInfo]); + const queryValue = useFormattedNeonQuery(vendorInfo?.sql); return ( SQL Query diff --git a/studio/src/components/Timeline/DetailsList/spans/FetchSpan/hooks.ts b/studio/src/components/Timeline/DetailsList/spans/FetchSpan/hooks.ts new file mode 100644 index 000000000..507c51816 --- /dev/null +++ b/studio/src/components/Timeline/DetailsList/spans/FetchSpan/hooks.ts @@ -0,0 +1,29 @@ +import { useMemo } from "react"; +import { format } from "sql-formatter"; + +export function useFormattedNeonQuery( + sql: { + query: string; + params?: string[]; + }, + options?: { + tabWidth?: number; + }, +) { + return useMemo(() => { + try { + const paramsFromNeon = sql.params ?? []; + // NOTE - sql-formatter expects the index in the array to match the `$nr` syntax from postgres + // this makes the 0th index unused, but it makes the rest of the indices match the `$1`, `$2`, etc. + const params = ["", ...paramsFromNeon]; + return format(sql.query, { + language: "postgresql", + params, + tabWidth: options?.tabWidth ?? 2, + }); + } catch (_e) { + // Being very defensive soz + return sql?.query ?? ""; + } + }, [sql, options?.tabWidth]); +} diff --git a/studio/src/components/Timeline/DetailsList/spans/FetchSpan/index.ts b/studio/src/components/Timeline/DetailsList/spans/FetchSpan/index.ts new file mode 100644 index 000000000..9b9a777a6 --- /dev/null +++ b/studio/src/components/Timeline/DetailsList/spans/FetchSpan/index.ts @@ -0,0 +1,2 @@ +export { FetchSpan } from "./FetchSpan"; +export { useFormattedNeonQuery } from "./hooks"; diff --git a/studio/src/components/Timeline/DetailsList/spans/index.ts b/studio/src/components/Timeline/DetailsList/spans/index.ts index 376d7ec5a..9a9476187 100644 --- a/studio/src/components/Timeline/DetailsList/spans/index.ts +++ b/studio/src/components/Timeline/DetailsList/spans/index.ts @@ -1,3 +1,4 @@ export { GenericSpan } from "./GenericSpan"; export { IncomingRequest } from "./IncomingRequest"; export { FetchSpan } from "./FetchSpan"; +export { useFormattedNeonQuery } from "./FetchSpan"; diff --git a/studio/src/components/Timeline/index.ts b/studio/src/components/Timeline/index.ts index e7b94f767..3c94a1575 100644 --- a/studio/src/components/Timeline/index.ts +++ b/studio/src/components/Timeline/index.ts @@ -6,5 +6,6 @@ export { BodyViewerV2, CollapsibleKeyValueTableV2, TimelineListDetails, + useFormattedNeonQuery, } from "./DetailsList"; export { TimelineProvider } from "./context"; diff --git a/studio/src/pages/RequestorPage/FormDataForm/FormDataForm.tsx b/studio/src/pages/RequestorPage/FormDataForm/FormDataForm.tsx index 9a50362e3..adc782158 100644 --- a/studio/src/pages/RequestorPage/FormDataForm/FormDataForm.tsx +++ b/studio/src/pages/RequestorPage/FormDataForm/FormDataForm.tsx @@ -1,6 +1,6 @@ +import { CodeMirrorInput } from "@/components/CodeMirrorEditor"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; import { cn, noop } from "@/utils"; import { FileIcon, TrashIcon } from "@radix-ui/react-icons"; import { useCallback, useRef, useState } from "react"; @@ -18,6 +18,7 @@ import type { type Props = { keyValueParameters: FormDataParameter[]; onChange: ChangeFormDataParametersHandler; + onSubmit?: () => void; }; type FormDataRowProps = { @@ -27,6 +28,7 @@ type FormDataRowProps = { onChangeKey?: (key: string) => void; onChangeValue: (value: FormDataParameter["value"]) => void; removeValue?: () => void; + onSubmit?: () => void; }; const FormDataFormRow = (props: FormDataRowProps) => { @@ -37,6 +39,7 @@ const FormDataFormRow = (props: FormDataRowProps) => { onChangeValue, removeValue, parameter, + onSubmit, } = props; const { enabled, key, value } = parameter; const [isHovering, setIsHovering] = useState(false); @@ -67,23 +70,23 @@ const FormDataFormRow = (props: FormDataRowProps) => { return handler(); }} /> - onChangeKey?.(e.target.value)} - className="w-28 h-8 bg-transparent shadow-none px-2 py-0 text-sm border-none" + onChange={(value) => onChangeKey?.(value ?? "")} + onSubmit={onSubmit} /> {value.type === "text" && ( - - onChangeValue({ value: e.target.value, type: "text" }) + onChange={(value) => + onChangeValue({ value: value ?? "", type: "text" }) } - className="h-8 flex-grow bg-transparent shadow-none px-2 py-0 text-sm border-none" + onSubmit={onSubmit} /> )} {value.type === "file" && ( @@ -129,7 +132,7 @@ const FormDataFormRow = (props: FormDataRowProps) => { }; export const FormDataForm = (props: Props) => { - const { onChange, keyValueParameters } = props; + const { onChange, keyValueParameters, onSubmit } = props; return (
@@ -160,6 +163,7 @@ export const FormDataForm = (props: Props) => { keyValueParameters.filter(({ id }) => parameter.id !== id), ); }} + onSubmit={onSubmit} /> ); })} diff --git a/studio/src/pages/RequestorPage/KeyValueForm/KeyValueForm.tsx b/studio/src/pages/RequestorPage/KeyValueForm/KeyValueForm.tsx index cc4728639..0a6dca71b 100644 --- a/studio/src/pages/RequestorPage/KeyValueForm/KeyValueForm.tsx +++ b/studio/src/pages/RequestorPage/KeyValueForm/KeyValueForm.tsx @@ -17,6 +17,8 @@ import type { type Props = { keyValueParameters: KeyValueParameter[]; onChange: ChangeKeyValueParametersHandler; + onSubmit?: () => void; + keyPlaceholder?: string; }; type KeyValueRowProps = { @@ -26,6 +28,8 @@ type KeyValueRowProps = { onChangeKey?: (key: string) => void; onChangeValue: (value: string) => void; removeValue?: () => void; + onSubmit?: () => void; + keyPlaceholder?: string; }; export const KeyValueRow = (props: KeyValueRowProps) => { @@ -36,6 +40,8 @@ export const KeyValueRow = (props: KeyValueRowProps) => { onChangeValue, removeValue, parameter, + onSubmit, + keyPlaceholder = "name", } = props; const { enabled, key, value } = parameter; const [isHovering, setIsHovering] = useState(false); @@ -57,15 +63,17 @@ export const KeyValueRow = (props: KeyValueRowProps) => { onChangeKey?.(value ?? "")} + onSubmit={onSubmit} /> onChangeValue(value ?? "")} + onSubmit={onSubmit} />
{ }; export const KeyValueForm = (props: Props) => { - const { onChange, keyValueParameters } = props; + const { onChange, keyValueParameters, onSubmit, keyPlaceholder } = props; return (
@@ -115,6 +123,8 @@ export const KeyValueForm = (props: Props) => { keyValueParameters.filter(({ id }) => parameter.id !== id), ); }} + onSubmit={onSubmit} + keyPlaceholder={keyPlaceholder} /> ); })} diff --git a/studio/src/pages/RequestorPage/LogsTable/LogsTable.tsx b/studio/src/pages/RequestorPage/LogsTable/LogsTable.tsx index 25d02c5e5..249790a28 100644 --- a/studio/src/pages/RequestorPage/LogsTable/LogsTable.tsx +++ b/studio/src/pages/RequestorPage/LogsTable/LogsTable.tsx @@ -1,13 +1,15 @@ import { useOrphanLogs } from "@/hooks"; -import { type MizuOrphanLog, useOtelTrace } from "@/queries"; +import { useOtelTrace } from "@/queries"; import { LogsEmptyState } from "./Empty"; -import { LogRow } from "./LogsTableRow"; +import { LogsTableRow } from "./LogsTableRow"; +import { useLogsWithEvents } from "./data"; +import type { LogEntry } from "./types"; type Props = { traceId?: string; }; -const EMPTY_LIST: MizuOrphanLog[] = []; +const EMPTY_LIST: LogEntry[] = []; export function LogsTable({ traceId }: Props) { if (!traceId) { @@ -21,10 +23,16 @@ const LogsTableWithTraceId = ({ traceId }: { traceId: string }) => { const { data: spans } = useOtelTrace(traceId); const logs = useOrphanLogs(traceId, spans ?? []); - return ; + // Here we insert relevant events that happened. + // For now, we're just looking for Neon database queries. + // Jacco is going to add exceptions, then we should consider additional things like + // fetches, etc. + const logsWithEvents = useLogsWithEvents(spans ?? [], logs); + + return ; }; -function LogsTableContent({ logs }: { logs: MizuOrphanLog[] }) { +function LogsTableContent({ logs }: { logs: LogEntry[] }) { return (
{logs.length === 0 ? ( @@ -32,7 +40,7 @@ function LogsTableContent({ logs }: { logs: MizuOrphanLog[] }) { ) : (
{logs.map((log) => ( - + ))}
)} diff --git a/studio/src/pages/RequestorPage/LogsTable/LogsTableRow.tsx b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/LogRow.tsx similarity index 94% rename from studio/src/pages/RequestorPage/LogsTable/LogsTableRow.tsx rename to studio/src/pages/RequestorPage/LogsTable/LogsTableRow/LogRow.tsx index 7febce674..3908608cd 100644 --- a/studio/src/pages/RequestorPage/LogsTable/LogsTableRow.tsx +++ b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/LogRow.tsx @@ -11,15 +11,12 @@ import { } from "@/components/ui/tooltip"; import { useCopyToClipboard } from "@/hooks"; import type { MizuOrphanLog } from "@/queries"; -import { cn, safeParseJson } from "@/utils"; +import { cn, isJson } from "@/utils"; import { CopyIcon } from "@radix-ui/react-icons"; import { useState } from "react"; +import { formatTimestamp } from "./shared"; -type LogRowProps = { - log: MizuOrphanLog; -}; - -export function LogRow({ log }: LogRowProps) { +export function LogRow({ log }: { log: MizuOrphanLog }) { const bgColor = getBgColorForLevel(log.level); const textColor = getTextColorForLevel(log.level); const [isExpanded, setIsExpanded] = useState(false); @@ -77,7 +74,7 @@ export function LogRow({ log }: LogRowProps) {

Message:

- {safeParseJson(log.message) ? ( + {isJson(log.message) ? (
                     {JSON.stringify(JSON.parse(log.message), null, 2)}
                   
@@ -163,11 +160,3 @@ function getIconColor(level: MizuOrphanLog["level"]) { return "bg-gray-500"; } } - -function formatTimestamp(timestamp: Date) { - return timestamp.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} diff --git a/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/LogsTableRow.tsx b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/LogsTableRow.tsx new file mode 100644 index 000000000..a25240836 --- /dev/null +++ b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/LogsTableRow.tsx @@ -0,0 +1,14 @@ +import { type LogEntry, isNeonEvent } from "../types"; +import { LogRow } from "./LogRow"; +import { NeonEventRow } from "./NeonEventRow"; + +type LogRowProps = { + log: LogEntry; +}; + +export function LogsTableRow({ log }: LogRowProps) { + if (isNeonEvent(log)) { + return ; + } + return ; +} diff --git a/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/NeonEventRow.tsx b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/NeonEventRow.tsx new file mode 100644 index 000000000..98e0b20d3 --- /dev/null +++ b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/NeonEventRow.tsx @@ -0,0 +1,128 @@ +import NeonLogo from "@/assets/NeonLogo.svg"; +import { CodeMirrorSqlEditor } from "@/components/CodeMirrorEditor"; +import { useFormattedNeonQuery } from "@/components/Timeline"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useCopyToClipboard } from "@/hooks"; +import { cn, getString, noop } from "@/utils"; +import { CopyIcon } from "@radix-ui/react-icons"; +import { useState } from "react"; +import type { NeonEvent } from "../types"; +import { formatTimestamp } from "./shared"; + +export function NeonEventRow({ log }: { log: NeonEvent }) { + const [isExpanded, setIsExpanded] = useState(false); + // we don't want the focus ring to be visible when the user is selecting the row with the mouse + const [isMouseSelected, setIsMouseSelected] = useState(false); + const { isCopied: isMessageCopied, copyToClipboard: copyMessageToClipboard } = + useCopyToClipboard(); + + const isError = !!log.errors?.length; + + const message = isError + ? "Neon DB Call failed" + : `Neon DB Call took ${log.duration}ms`; + + const bgColor = isError ? "bg-red-500/10" : "bg-green-500/10"; + + const queryValue = useFormattedNeonQuery(log.sql); + + return ( +
setIsExpanded(e.currentTarget.open)} + onMouseDown={() => setIsMouseSelected(true)} + onBlur={() => setIsMouseSelected(false)} + > + +
+ +
+
+ {message}{" "} + + {log.command ?? "Unknown command"} + +
+
+ {formatTimestamp(log.timestamp)} +
+
+
+
+
+

Duration:

+
+

{log.duration}ms

+
+
+ + {isError && ( +
+

Error:

+
+

+ {getString( + log.errors?.[0]?.attributes?.["exception.message"], + )} +

+
+
+ )} + + {!isError && ( +
+

Row Count:

+
+

{log.rowCount}

+
+
+ )} + +
+

Query:

+ +
+ + + + + + +

Message copied

+
+
+
+
+
+
+
+
+ ); +} diff --git a/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/index.ts b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/index.ts new file mode 100644 index 000000000..377d2b4d7 --- /dev/null +++ b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/index.ts @@ -0,0 +1 @@ +export { LogsTableRow } from "./LogsTableRow"; diff --git a/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/shared.ts b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/shared.ts new file mode 100644 index 000000000..0b0b2c3aa --- /dev/null +++ b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/shared.ts @@ -0,0 +1,7 @@ +export function formatTimestamp(timestamp: Date) { + return timestamp.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} diff --git a/studio/src/pages/RequestorPage/LogsTable/data.ts b/studio/src/pages/RequestorPage/LogsTable/data.ts new file mode 100644 index 000000000..20719599b --- /dev/null +++ b/studio/src/pages/RequestorPage/LogsTable/data.ts @@ -0,0 +1,57 @@ +import type { MizuOrphanLog } from "@/queries"; +import { + getErrorEvents, + getNeonSqlQuery, + getResponseBody, + isJson, + isNeonFetch, + safeParseJson, +} from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; +import { useMemo } from "react"; +import type { LogEntry, NeonEvent } from "./types"; + +export function useLogsWithEvents(spans: OtelSpan[], logs: MizuOrphanLog[]) { + // Here we can insert relevant events that happend, in order to link back to the timeline. + // For now, we're just looking for Neon database queries + return useMemo(() => { + const neonSpans = spans?.filter((span) => isNeonFetch(span)); + const neonEvents: NeonEvent[] = neonSpans?.map(neonSpanToEvent) ?? []; + if (neonEvents?.length) { + const result = [...logs, ...neonEvents]; + return result.sort( + (a, b) => a.timestamp.getTime() - b.timestamp.getTime(), + ); + } + + return logs; + }, [logs, spans]); +} + +function neonSpanToEvent(span: OtelSpan): NeonEvent { + const responseBody = getResponseBody(span); + const parsedResponseBody = + responseBody && isJson(responseBody) ? safeParseJson(responseBody) : null; + const rowCount = + parsedResponseBody && "rowCount" in parsedResponseBody + ? Number.parseInt(parsedResponseBody.rowCount ?? "") ?? null + : null; + + // E.g., "SELECT" + const command = + parsedResponseBody && "command" in parsedResponseBody + ? parsedResponseBody.command + : null; + const errorEvents = getErrorEvents(span); + + return { + id: span.span_id, + type: "neon-event", + errors: errorEvents, + timestamp: span.end_time, + sql: getNeonSqlQuery(span), + command, + rowCount, + duration: span.end_time.getTime() - span.start_time.getTime(), + }; +} diff --git a/studio/src/pages/RequestorPage/LogsTable/types.ts b/studio/src/pages/RequestorPage/LogsTable/types.ts new file mode 100644 index 000000000..451f969a5 --- /dev/null +++ b/studio/src/pages/RequestorPage/LogsTable/types.ts @@ -0,0 +1,24 @@ +import type { MizuOrphanLog } from "@/queries"; +import { objectWithKey } from "@/utils"; +import type { OtelEvent } from "@fiberplane/fpx-types"; + +export type NeonEvent = { + id: string; + type: "neon-event"; + timestamp: Date; + sql: { + query: string; + params: Array; + }; + duration: number; + rowCount: number | null; + /** E.g., "SELECT" (this is returned from the Neon API) */ + command: string | null; + errors?: OtelEvent[]; +}; + +export const isNeonEvent = (log: unknown): log is NeonEvent => { + return objectWithKey(log, "type") && log.type === "neon-event"; +}; + +export type LogEntry = MizuOrphanLog | NeonEvent; diff --git a/studio/src/pages/RequestorPage/RequestPanel/PathParamForm/PathParamForm.tsx b/studio/src/pages/RequestorPage/RequestPanel/PathParamForm/PathParamForm.tsx index 32f63fd46..36ca3687c 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/PathParamForm/PathParamForm.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/PathParamForm/PathParamForm.tsx @@ -9,6 +9,8 @@ import { createChangePathParamValue } from "./data"; type Props = { keyValueParameters: KeyValueParameter[]; onChange: ChangeKeyValueParametersHandler; + onSubmit?: () => void; + keyPlaceholder?: string; }; /** @@ -18,7 +20,7 @@ type Props = { * Remember: Path params are *computed* from the route pattern. */ export const PathParamForm = (props: Props) => { - const { onChange, keyValueParameters } = props; + const { onChange, keyValueParameters, onSubmit, keyPlaceholder } = props; return (
@@ -39,6 +41,8 @@ export const PathParamForm = (props: Props) => { keyValueParameters, parameter, )} + onSubmit={onSubmit} + keyPlaceholder={keyPlaceholder} /> ); })} diff --git a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx index 2f21c9dab..084086d3e 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx @@ -30,6 +30,7 @@ type RequestPanelProps = { setIgnoreAiInputsBanner: Dispatch>; websocketState: WebSocketState; sendWebsocketMessage: (message: string) => void; + onSubmit: () => void; }; export const RequestPanel = memo(function RequestPanel( @@ -46,6 +47,7 @@ export const RequestPanel = memo(function RequestPanel( setIgnoreAiInputsBanner, websocketState, sendWebsocketMessage, + onSubmit, } = props; const { @@ -169,10 +171,12 @@ export const RequestPanel = memo(function RequestPanel( }} /> { setQueryParams(params); }} + onSubmit={onSubmit} /> {pathParams.length > 0 ? ( <> @@ -182,10 +186,12 @@ export const RequestPanel = memo(function RequestPanel( className="mt-4" /> { setPathParams(params); }} + onSubmit={onSubmit} /> ) : null} @@ -203,10 +209,12 @@ export const RequestPanel = memo(function RequestPanel( }} /> { setRequestHeaders(headers); }} + onSubmit={onSubmit} /> {shouldShowBody && ( @@ -234,6 +242,7 @@ export const RequestPanel = memo(function RequestPanel( onChange={setBody} value={body.value} maxHeight="800px" + onSubmit={onSubmit} /> )} {body.type === "form-data" && ( @@ -246,6 +255,7 @@ export const RequestPanel = memo(function RequestPanel( value: params, }); }} + onSubmit={onSubmit} /> )} {body.type === "file" && ( diff --git a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx index 80acff811..d3f9eabc2 100644 --- a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx +++ b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx @@ -182,6 +182,7 @@ export const RequestorPageContent: React.FC = ( setIgnoreAiInputsBanner={setIgnoreAiInputsBanner} websocketState={websocketState} sendWebsocketMessage={sendWebsocketMessage} + onSubmit={onSubmit} /> ); diff --git a/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts b/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts index 07d02d223..07d2ca528 100644 --- a/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts +++ b/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts @@ -5,6 +5,7 @@ import { enforceTerminalDraftParameter } from "../../KeyValueForm"; import { findMatchedRoute } from "../../routes"; import { updateContentTypeHeaderInState } from "../content-type"; import { setBodyTypeInState } from "../set-body-type"; +import { getVisibleRequestPanelTabs } from "../tabs"; import { addBaseUrl, extractMatchedPathParams, @@ -73,6 +74,19 @@ export const requestResponseSlice: StateCreator< // Update other state properties based on the new method and request type // (e.g., activeRoute, visibleRequestsPanelTabs, activeRequestsPanelTab, etc.) // You might want to move some of this logic to separate functions or slices + + // Update visibleRequestsPanelTabs based on the new method and request type + state.visibleRequestsPanelTabs = getVisibleRequestPanelTabs({ + requestType, + method, + }); + + // Ensure the activeRequestsPanelTab is valid + state.activeRequestsPanelTab = state.visibleRequestsPanelTabs.includes( + state.activeRequestsPanelTab, + ) + ? state.activeRequestsPanelTab + : state.visibleRequestsPanelTabs[0]; }), setPathParams: (pathParams) => diff --git a/studio/src/pages/RequestorPage/useRequestorSubmitHandler.ts b/studio/src/pages/RequestorPage/useRequestorSubmitHandler.ts index de1ecf58b..cb7639ae5 100644 --- a/studio/src/pages/RequestorPage/useRequestorSubmitHandler.ts +++ b/studio/src/pages/RequestorPage/useRequestorSubmitHandler.ts @@ -44,8 +44,9 @@ export function useRequestorSubmitHandler({ const { addServiceUrlIfBarePath } = useServiceBaseUrl(); const { activeHistoryResponseTraceId } = useRequestorStore(); - return useHandler((e: React.FormEvent) => { - e.preventDefault(); + // NOTE - We make the submit handler optional to make it easier to call this as a standalone function + return useHandler((e?: React.FormEvent) => { + e?.preventDefault?.(); // TODO - Make it clear in the UI that we're auto-adding this header const canHaveBody = !isWsRequest(requestType) && !["GET", "DELETE"].includes(method); diff --git a/studio/src/utils/vendorify-traces.ts b/studio/src/utils/vendorify-traces.ts index 0faceb78c..a2a28660a 100644 --- a/studio/src/utils/vendorify-traces.ts +++ b/studio/src/utils/vendorify-traces.ts @@ -176,7 +176,7 @@ const isOpenAIFetch = (span: OtelSpan) => { }; // TODO - Make this a bit more robust? -const isNeonFetch = (span: OtelSpan) => { +export const isNeonFetch = (span: OtelSpan) => { return !!span.attributes["http.request.header.neon-connection-string"]; }; @@ -190,7 +190,7 @@ const isAnthropicFetch = (span: OtelSpan) => { } }; -function getNeonSqlQuery(span: OtelSpan) { +export function getNeonSqlQuery(span: OtelSpan) { const body = getRequestBody(span); // const body = getString(span.attributes["fpx.request.body"]); if (!body) { diff --git a/www/src/assets/blog/2024-10-03-neon-query-logs-expanded.png b/www/src/assets/blog/2024-10-03-neon-query-logs-expanded.png new file mode 100644 index 000000000..74768ff90 Binary files /dev/null and b/www/src/assets/blog/2024-10-03-neon-query-logs-expanded.png differ diff --git a/www/src/assets/blog/2024-10-03-neon-query-logs.png b/www/src/assets/blog/2024-10-03-neon-query-logs.png new file mode 100644 index 000000000..e1da3ad9c Binary files /dev/null and b/www/src/assets/blog/2024-10-03-neon-query-logs.png differ diff --git a/www/src/content/blog/2024-10-03-neon-query-logs.mdx b/www/src/content/blog/2024-10-03-neon-query-logs.mdx new file mode 100644 index 000000000..b8ae3d3d3 --- /dev/null +++ b/www/src/content/blog/2024-10-03-neon-query-logs.mdx @@ -0,0 +1,51 @@ +--- +title: "Neon Queries in Studio, O My!" +description: Get rich context logs from Neon database queries automatically in the Studio UI +slug: neon-query-logs-in-studio +date: 2024-10-03 +author: Brett Beutell +tags: + - Hono.js + - Neon + - HONC + - Postgres +--- + +import PackageManagers from "@/components/PackageManagers.astro"; + +We're shipping a small but mighty feature in Fiberplane Studio that will allow you to view query logs from Neon directly in the Studio logs table. + +![Neon Query Logs UI](@/assets/blog/2024-10-03-neon-query-logs.png) + +## TL;DR + +You know how the browser console gives you a heads up when a network request errored, or a resource failed to load? + +I like that pattern. It gives you context of what's going on in your app, right alongside any info you chose to print. + +Well, in that vein, we're shipping an enhancement in Studio that will put metadata about Neon queries right in the logs table. + +When you click on a Neon entry in the logs table, you'll see an expanded view with the SQL query that was executed, the duration, and the row count. +If there was an error, you'll see that too. + +![Expanded Neon Query Log](@/assets/blog/2024-10-03-neon-query-logs-expanded.png) + +No configuration necessary—if you're running Neon in a Hono app, you get some extra info without any work on your part. + +## This is only the beginning + +The logs table in Studio is getting richer by the day. + +With [plans](https://github.com/fiberplane/fpx/issues/292) to also hook into Drizzle's query logs, Fiberplane Studio will be able to render useful query logs for any database that Drizzle ORM supports. + +Are there any other items you think deserve to be rendered in the logs table? Let us know on [Discord](https://discord.gg/cqdY6SpfVR) or [GitHub](https://github.com/fiberplane/fpx/issues)! + +## Take it for a spin + +This new feature is well-suited for 🪿 [HONC](https://honc.dev) apps. + +If you want to give it ago, spin up a new Hono app with a Neon database using [the create-honc-app CLI](/blog/faster-neon-setup-create-honc-app), and try it out! + + + +🪿 Honc! Honc!