mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-22 20:25:38 +00:00
feat: implement WebSocket response sorting and enhance message handling
- Added WSResponseSortOrder component for toggling message sort order. - Updated WSMessagesList to accept and utilize sort order. - Refactored message handling to use 'type' instead of 'direction'. - Enhanced response state management to include sort order.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
|
||||
import { IconWebSocket } from 'components/Icons/Grpc';
|
||||
import classnames from "classnames"
|
||||
import SingleLineEditor from 'components/SingleLineEditor/index';
|
||||
import { requestUrlChanged } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -16,7 +17,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { theme, displayedTheme } = useTheme();
|
||||
const [isConnectionActive, setIsConnectionActive] = useState(false);
|
||||
// TODO: repear, better state for connecting
|
||||
// TODO: reaper, better state for connecting
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const url = getPropertyFromDraftOrRequest(item, 'request.url');
|
||||
const saveShortcut = isMacOS() ? '⌘S' : 'Ctrl+S';
|
||||
@@ -55,6 +56,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
|
||||
.then(() => {
|
||||
toast.success('WebSocket connection closed');
|
||||
setIsConnectionActive(false);
|
||||
setIsConnecting(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to close WebSocket connection:', err);
|
||||
@@ -72,7 +74,8 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
|
||||
};
|
||||
|
||||
const handleConnect = (e) => {
|
||||
connectWS(item, collection, undefined, undefined, {connectOnly:true});
|
||||
setIsConnecting(true)
|
||||
connectWS(item, collection, undefined, undefined, {connectOnly:true});
|
||||
};
|
||||
|
||||
const onSave = (finalValue) => {
|
||||
@@ -119,7 +122,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
|
||||
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
|
||||
<div className="infotip" onClick={handleCloseConnection}>
|
||||
<IconPlugConnectedX
|
||||
color={theme.requestTabs.icon.color}
|
||||
color={theme.colors.text.danger}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className="cursor-pointer"
|
||||
@@ -133,8 +136,12 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
|
||||
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
|
||||
<div className="infotip" onClick={handleConnect}>
|
||||
<IconPlugConnected
|
||||
className="cursor-pointer"
|
||||
color={theme.requestTabPanel.url.icon}
|
||||
className={
|
||||
classnames("cursor-pointer",{
|
||||
"animate-pulse": isConnecting
|
||||
})
|
||||
}
|
||||
color={theme.colors.text.green}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
overflow-y: auto;
|
||||
overflow-y: auto;
|
||||
|
||||
.ws-message:not(:last-child) {
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
.ws-message:not(:last-child).open {
|
||||
border-bottom-width: 0px;
|
||||
}
|
||||
|
||||
.ws-incoming {
|
||||
background: ${(props) => props.theme.bg};
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconChevronUp, IconChevronDown, IconArrowUpRight, IconArrowDownLeft } from '@tabler/icons';
|
||||
import { IconChevronUp, IconInfoCircle, IconChevronDown, IconArrowUpRight, IconArrowDownLeft } from '@tabler/icons';
|
||||
import CodeEditor from 'components/CodeEditor/index';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import _ from 'lodash';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
// Example message structure: { direction: 'incoming' | 'outgoing', timestamp, data }
|
||||
|
||||
const parseContent = (content) => {
|
||||
if (typeof content === 'string') {
|
||||
let isJSON = false;
|
||||
let resultContent = content;
|
||||
let trimmedContent = content;
|
||||
try {
|
||||
JSON.parse(content);
|
||||
isJSON = true;
|
||||
resultContent = JSON.stringify(resultContent, null, 2);
|
||||
trimmedContent = JSON.stringify(resultContent, null, 0);
|
||||
} catch (err) {
|
||||
// digest error
|
||||
}
|
||||
|
||||
return {
|
||||
type: isJSON ? 'application/json' : 'text/plain',
|
||||
content: resultContent,
|
||||
sliced: trimmedContent.slice(0, 30)
|
||||
};
|
||||
}
|
||||
const getContentMeta = (content) => {
|
||||
if (typeof content === 'object') {
|
||||
return {
|
||||
type: 'application/json',
|
||||
content: JSON.stringify(content, null, 2),
|
||||
sliced: JSON.stringify(content, null, 0).slice(0, 30)
|
||||
isJSON: true,
|
||||
content: JSON.stringify(content, null, 0)
|
||||
};
|
||||
}
|
||||
try {
|
||||
return {
|
||||
isJSON: true,
|
||||
content: JSON.stringify(JSON.parse(content), null, 0)
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
isJSON: false,
|
||||
content: content
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const parseContent = (content) => {
|
||||
let contentMeta = getContentMeta(content);
|
||||
return {
|
||||
type: contentMeta.isJSON ? 'application/json' : 'text/plain',
|
||||
content: contentMeta.isJSON ? JSON.stringify(JSON.parse(contentMeta.content), null, 2) : contentMeta.content,
|
||||
sliced: contentMeta.content.slice(0, 30)
|
||||
};
|
||||
};
|
||||
|
||||
const getDataTypeText = (type) => {
|
||||
@@ -46,28 +46,56 @@ const getDataTypeText = (type) => {
|
||||
return textMap[type] ?? 'RAW';
|
||||
};
|
||||
|
||||
const WSMessageItem = ({ message, defaultOpen }) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen ?? false);
|
||||
/**
|
||||
*
|
||||
* @param {"incoming"|"outgoing"|"info"} type
|
||||
*/
|
||||
const TypeIcon = ({type})=>{
|
||||
const commonProps = {
|
||||
size: 18
|
||||
}
|
||||
return {
|
||||
"incoming": <IconArrowDownLeft {...commonProps} />,
|
||||
"outgoing": <IconArrowUpRight {...commonProps} />,
|
||||
"info": <IconInfoCircle {...commonProps} />
|
||||
}[type]
|
||||
}
|
||||
|
||||
const WSMessageItem = ({ message, isLast }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
const isIncoming = message.direction === 'incoming';
|
||||
const isIncoming = message.type === 'incoming';
|
||||
const isInfo = message.type === 'info';
|
||||
let parsedContent = parseContent(message.message);
|
||||
|
||||
const dataType = getDataTypeText(parsedContent.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames('ws-message flex flex-col rounded border p-2', {
|
||||
ref={(node) => {
|
||||
if (!node) return;
|
||||
if (isLast) node.scrollIntoView();
|
||||
}}
|
||||
className={classnames('ws-message flex flex-col py-2', {
|
||||
'ws-incoming': isIncoming,
|
||||
'ws-outgoing': !isIncoming
|
||||
'ws-outgoing': !isIncoming,
|
||||
'open': isOpen
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between"
|
||||
className={
|
||||
classnames("flex items-center justify-between",{
|
||||
'cursor-not-allowed': isInfo,
|
||||
'cursor-pointer': !isInfo
|
||||
})
|
||||
}
|
||||
onClick={(e) => {
|
||||
setIsOpen(!isOpen);
|
||||
if(!isInfo){
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex">
|
||||
@@ -77,10 +105,9 @@ const WSMessageItem = ({ message, defaultOpen }) => {
|
||||
isIncoming ? 'text-blue-700' : 'text-green-700'
|
||||
)}
|
||||
>
|
||||
{isIncoming ? <IconArrowDownLeft size={18} /> : <IconArrowUpRight size={18} />}
|
||||
<TypeIcon type={message.type} />
|
||||
</span>
|
||||
{!isOpen ? <span className="ml-3">{parsedContent.sliced}</span> : null}
|
||||
{isOpen ? <span className="ml-3 text-xs font-bold">{dataType}</span> : null}
|
||||
<span className="ml-3">{parsedContent.sliced}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{message.timestamp && (
|
||||
@@ -96,7 +123,11 @@ const WSMessageItem = ({ message, defaultOpen }) => {
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="mt-2 h-[300px]">
|
||||
<div className="mt-2 h-[300px] w-full">
|
||||
<div className="flex">
|
||||
<div className="flex-grow"></div>
|
||||
{isOpen ? <span className="text-xs mr-1 font-bold">{dataType}</span> : null}
|
||||
</div>
|
||||
<CodeEditor
|
||||
mode={parsedContent.type}
|
||||
theme={displayedTheme}
|
||||
@@ -109,17 +140,23 @@ const WSMessageItem = ({ message, defaultOpen }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const WSMessagesList = ({ messages = [] }) => {
|
||||
const WSMessagesList = ({ order = -1, messages = [] }) => {
|
||||
if (!messages.length) {
|
||||
return <div className="p-4 text-gray-500">No messages yet.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ws-messages-list flex flex-col gap-2 mt-4">
|
||||
{messages.map((msg, idx,src) => {
|
||||
const isLast = idx === src.length-1
|
||||
return <WSMessageItem id={idx} message={msg} defaultOpen={isLast} />;
|
||||
})}
|
||||
<StyledWrapper className="ws-messages-list flex flex-col gap-1 mt-4">
|
||||
{messages
|
||||
.toSorted((x, y) => {
|
||||
let a = order == -1 ? x : y
|
||||
let b = order == -1 ? y : x
|
||||
return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
})
|
||||
.map((msg, idx, src) => {
|
||||
const isLast = src.length - 1 === idx;
|
||||
return <WSMessageItem isLast={isLast} id={idx} message={msg} />;
|
||||
})}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
font-size: 0.8125rem;
|
||||
color: ${(props) => props.theme.requestTabPanel.responseStatus};
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { IconSortDescending2, IconSortAscending2 } from '@tabler/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { wsUpdateResponseSortOrder } from 'providers/ReduxStore/slices/collections/index';
|
||||
|
||||
const WSResponseSortOrder = ({ collection, item }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const order = item.response?.initiatedWsResponse?.sortOrder ?? -1
|
||||
|
||||
const toggleSortOrder = ()=>{
|
||||
dispatch(
|
||||
wsUpdateResponseSortOrder({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button onClick={toggleSortOrder} title={order > 0 ? 'Latest Last' : 'Latest First'}>
|
||||
{ order == -1
|
||||
? <IconSortAscending2 size={16} strokeWidth={1.5} />
|
||||
: <IconSortDescending2 size={16} strokeWidth={1.5} />}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WSResponseSortOrder;
|
||||
@@ -4,13 +4,11 @@ import wsStatusCodePhraseMap from './get-ws-status-code-phrase';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const WSStatusCode = ({ status, text }) => {
|
||||
// gRPC status codes: 0 is success, anything else is an error
|
||||
const getTabClassname = (status) => {
|
||||
const isPending = text === 'PENDING' || text === 'STREAMING';
|
||||
return classnames('ml-2', {
|
||||
'text-ok': parseInt(status) === 0,
|
||||
'text-pending': isPending,
|
||||
'text-error': parseInt(status) > 0 && !isPending
|
||||
// ok if normal connect and normal closure
|
||||
'text-ok': parseInt(status) === 0 || parseInt(status) === 1000,
|
||||
'text-error': parseInt(status) !== 1000 && parseInt(status) !== 0
|
||||
});
|
||||
};
|
||||
|
||||
@@ -18,7 +16,7 @@ const WSStatusCode = ({ status, text }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className={getTabClassname(status)}>
|
||||
{Number.isInteger(status) ? <div className="mr-1">{status}</div> : null}
|
||||
{Number.isInteger(status) && status != 0 ? <div className="mr-1">{status}</div> : null}
|
||||
{statusText && <div>{statusText}</div>}
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import find from 'lodash/find';
|
||||
import classnames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import Overlay from '../Overlay';
|
||||
import Placeholder from '../Placeholder';
|
||||
import WSResponseHeaders from './WSResponseHeaders';
|
||||
import WSStatusCode from './WSStatusCode';
|
||||
import ResponseTime from '../ResponseTime/index';
|
||||
import Timeline from '../Timeline';
|
||||
@@ -15,6 +13,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
import ResponseLayoutToggle from '../ResponseLayoutToggle';
|
||||
import Tab from 'components/Tab';
|
||||
import WSMessagesList from './WSMessagesList';
|
||||
import WSResponseSortOrder from './WSResponseSortOrder';
|
||||
|
||||
const WSResult = ({ response }) => {
|
||||
return response.isError ? (
|
||||
@@ -22,7 +21,7 @@ const WSResult = ({ response }) => {
|
||||
{response.error}
|
||||
</div>
|
||||
) : (
|
||||
<WSMessagesList messages={response.responses || []} />
|
||||
<WSMessagesList order={response?.initiatedWsResponse?.sortOrder} messages={response.responses || []} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,9 +51,6 @@ const WSResponsePane = ({ item, collection }) => {
|
||||
case 'response': {
|
||||
return <WSResult response={response} />;
|
||||
}
|
||||
case 'headers': {
|
||||
return <WSResponseHeaders metadata={response.metadata} />;
|
||||
}
|
||||
case 'timeline': {
|
||||
return <Timeline collection={collection} item={item} />;
|
||||
}
|
||||
@@ -95,11 +91,6 @@ const WSResponsePane = ({ item, collection }) => {
|
||||
label: 'Messages',
|
||||
count: Array.isArray(response.responses) ? response.responses.length : 0
|
||||
},
|
||||
{
|
||||
name: 'headers',
|
||||
label: 'Metadata',
|
||||
count: Array.isArray(response.metadata) ? response.metadata.length : 0
|
||||
},
|
||||
{
|
||||
name: 'timeline',
|
||||
label: 'Timeline'
|
||||
@@ -130,6 +121,7 @@ const WSResponsePane = ({ item, collection }) => {
|
||||
<>
|
||||
<ResponseLayoutToggle />
|
||||
<ResponseClear item={item} collection={collection} />
|
||||
<WSResponseSortOrder item={item} collection={collection} />
|
||||
<WSStatusCode
|
||||
status={response.statusCode}
|
||||
text={response.statusText}
|
||||
|
||||
@@ -93,6 +93,7 @@ const initiatedWsResponse = {
|
||||
body: '',
|
||||
size: 0,
|
||||
duration: 0,
|
||||
sortOrder: -1,
|
||||
responses: [],
|
||||
isError: false,
|
||||
error: null,
|
||||
@@ -345,7 +346,7 @@ export const collectionsSlice = createSlice({
|
||||
enabled: true,
|
||||
type: 'text',
|
||||
uid: uuid(),
|
||||
ephemeral: true,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2806,24 +2807,24 @@ export const collectionsSlice = createSlice({
|
||||
// Get current response state or create initial state
|
||||
const currentResponse = item.response || initiatedWsResponse;
|
||||
const timestamp = item?.requestSent?.timestamp;
|
||||
let updatedResponse = { ...currentResponse,
|
||||
let updatedResponse = {
|
||||
...currentResponse,
|
||||
isError: false,
|
||||
error: '',
|
||||
duration: Date.now() - (timestamp || Date.now())
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
// Process based on event type
|
||||
switch (eventType) {
|
||||
case 'message':
|
||||
const { message, direction } = eventData;
|
||||
const { message, type } = eventData;
|
||||
|
||||
// Add message to responses list
|
||||
updatedResponse.responses = [
|
||||
...(currentResponse?.responses || []),
|
||||
{
|
||||
message,
|
||||
direction,
|
||||
type,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
];
|
||||
@@ -2833,12 +2834,18 @@ export const collectionsSlice = createSlice({
|
||||
updatedResponse.status = 'CONNECTED';
|
||||
updatedResponse.statusText = 'CONNECTED';
|
||||
updatedResponse.statusCode = 0;
|
||||
updatedResponse.responses ||= []
|
||||
updatedResponse.responses.push({
|
||||
message: "Connected",
|
||||
type: "info",
|
||||
timestamp: Date.now()
|
||||
})
|
||||
break;
|
||||
|
||||
case 'close':
|
||||
const { code, reason } = eventData;
|
||||
updatedResponse.isError = false
|
||||
updatedResponse.error = ''
|
||||
updatedResponse.isError = false;
|
||||
updatedResponse.error = '';
|
||||
updatedResponse.status = 'CLOSED';
|
||||
updatedResponse.statusCode = code;
|
||||
updatedResponse.statusText = wsStatusCodes[code] || 'CLOSED';
|
||||
@@ -2848,6 +2855,12 @@ export const collectionsSlice = createSlice({
|
||||
if (code !== 1000) {
|
||||
updatedResponse.isError = true;
|
||||
updatedResponse.error = reason || `WebSocket closed with code ${code}`;
|
||||
}else{
|
||||
updatedResponse.responses.push({
|
||||
type: "info",
|
||||
message: "Closed",
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -2866,6 +2879,16 @@ export const collectionsSlice = createSlice({
|
||||
}
|
||||
|
||||
item.response = updatedResponse;
|
||||
},
|
||||
wsUpdateResponseSortOrder: (state, action) => {
|
||||
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, action.payload.itemUid);
|
||||
if (item) {
|
||||
item.response.initiatedWsResponse.sortOrder = item.response?.initiatedWsResponse?.sortOrder ? -item.response.initiatedWsResponse.sortOrder : -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2996,7 +3019,8 @@ export const {
|
||||
updateCollectionTagsList,
|
||||
updateActiveConnections,
|
||||
runWsRequestEvent,
|
||||
wsResponseReceived
|
||||
wsResponseReceived,
|
||||
wsUpdateResponseSortOrder
|
||||
} = collectionsSlice.actions;
|
||||
|
||||
export default collectionsSlice.reducer;
|
||||
|
||||
@@ -186,7 +186,7 @@ class WsClient {
|
||||
// Emit message sent event
|
||||
this.eventCallback('ws:message', requestId, collectionUid, {
|
||||
message: messageToSend,
|
||||
direction: 'outgoing',
|
||||
type: 'outgoing',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
@@ -274,14 +274,14 @@ class WsClient {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.eventCallback('ws:message', requestId, collectionUid, {
|
||||
message,
|
||||
direction: 'incoming',
|
||||
type: 'incoming',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
// If parsing fails, send as raw data
|
||||
this.eventCallback('ws:message', requestId, collectionUid, {
|
||||
message: data.toString(),
|
||||
direction: 'incoming',
|
||||
type: 'incoming',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user