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:
Siddharth Gelera
2025-09-16 19:34:27 +05:30
parent 8eefe7c68b
commit 1a1bfdce4c
9 changed files with 185 additions and 79 deletions

View File

@@ -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}
/>

View File

@@ -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};

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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;

View File

@@ -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()
});
}