added: status bar & console (#4922)

* added: status bar & console
This commit is contained in:
naman-bruno
2025-07-14 19:05:57 +05:30
committed by GitHub
parent 48989ceea9
commit b9da31d24e
26 changed files with 3907 additions and 138 deletions

View File

@@ -0,0 +1,293 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
background: ${(props) => props.theme.console.contentBg};
overflow: hidden;
.network-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
flex-shrink: 0;
}
.network-title {
display: flex;
align-items: center;
gap: 8px;
color: ${(props) => props.theme.console.titleColor};
font-size: 13px;
font-weight: 500;
.request-count {
color: ${(props) => props.theme.console.countColor};
font-size: 12px;
font-weight: 400;
}
}
.network-controls {
display: flex;
align-items: center;
gap: 8px;
}
.network-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0; /* Important for proper flex behavior */
}
.network-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: ${(props) => props.theme.console.emptyColor};
text-align: center;
gap: 8px;
padding: 40px 20px;
p {
margin: 0;
font-size: 14px;
font-weight: 500;
}
span {
font-size: 12px;
opacity: 0.7;
}
}
.requests-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
min-height: 0; /* Important for proper flex behavior */
}
.requests-header {
display: grid;
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
gap: 12px;
padding: 8px 16px;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.console.titleColor};
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.requests-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0; /* Important for proper scrolling */
}
.request-row {
display: grid;
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
gap: 12px;
padding: 6px 16px;
border-bottom: 1px solid ${(props) => props.theme.console.border};
cursor: pointer;
transition: background-color 0.1s ease;
font-size: 12px;
align-items: center;
&:hover {
background: ${(props) => props.theme.console.logHoverBg};
}
&.selected {
background: ${(props) => props.theme.console.buttonHoverBg};
border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
}
}
.method-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: white;
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 45px;
}
.status-badge {
font-weight: 600;
font-size: 12px;
}
.request-domain {
color: ${(props) => props.theme.console.messageColor};
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-path {
color: ${(props) => props.theme.console.messageColor};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
.request-time {
color: ${(props) => props.theme.console.timestampColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 11px;
}
.request-duration {
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 11px;
text-align: right;
}
.request-size {
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 11px;
text-align: right;
}
.filter-dropdown {
position: relative;
}
.filter-dropdown-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: transparent;
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 4px;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
transition: all 0.2s ease;
font-size: 12px;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
color: ${(props) => props.theme.console.buttonHoverColor};
}
.filter-summary {
font-weight: 500;
min-width: 24px;
text-align: center;
}
}
.filter-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 200px;
max-width: 250px;
background: ${(props) => props.theme.console.dropdownBg};
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.filter-dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: ${(props) => props.theme.console.dropdownHeaderBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.console.titleColor};
}
.filter-toggle-all {
background: transparent;
border: none;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
font-size: 11px;
font-weight: 500;
padding: 2px 4px;
border-radius: 2px;
transition: all 0.2s ease;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
}
}
.filter-dropdown-options {
padding: 4px 0;
}
.filter-option {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: ${(props) => props.theme.console.optionHoverBg};
}
input[type="checkbox"] {
margin: 0 8px 0 0;
width: 14px;
height: 14px;
accent-color: ${(props) => props.theme.console.checkboxColor};
}
}
.filter-option-content {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.filter-option-label {
color: ${(props) => props.theme.console.optionLabelColor};
font-size: 12px;
font-weight: 400;
}
.filter-option-count {
color: ${(props) => props.theme.console.optionCountColor};
font-size: 11px;
font-weight: 400;
margin-left: auto;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,302 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
IconFilter,
IconChevronDown,
IconNetwork,
} from '@tabler/icons';
import {
updateNetworkFilter,
toggleAllNetworkFilters,
setSelectedRequest
} from 'providers/ReduxStore/slices/logs';
import StyledWrapper from './StyledWrapper';
const MethodBadge = ({ method }) => {
const getMethodColor = (method) => {
switch (method?.toUpperCase()) {
case 'GET': return '#10b981';
case 'POST': return '#8b5cf6';
case 'PUT': return '#f59e0b';
case 'DELETE': return '#ef4444';
case 'PATCH': return '#06b6d4';
case 'HEAD': return '#6b7280';
case 'OPTIONS': return '#84cc16';
default: return '#6b7280';
}
};
return (
<span
className="method-badge"
style={{ backgroundColor: getMethodColor(method) }}
>
{method?.toUpperCase() || 'GET'}
</span>
);
};
const StatusBadge = ({ status, statusCode }) => {
const getStatusColor = (code) => {
if (code >= 200 && code < 300) return '#10b981';
if (code >= 300 && code < 400) return '#f59e0b';
if (code >= 400 && code < 500) return '#ef4444';
if (code >= 500) return '#dc2626';
return '#6b7280';
};
const displayStatus = statusCode || status;
return (
<span
className="status-badge"
style={{ color: getStatusColor(statusCode) }}
>
{displayStatus}
</span>
);
};
const NetworkFilterDropdown = ({ filters, requestCounts, onFilterToggle, onToggleAll }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
const allFiltersEnabled = Object.values(filters).every(f => f);
const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled);
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="filter-dropdown" ref={dropdownRef}>
<button
className="filter-dropdown-trigger"
onClick={() => setIsOpen(!isOpen)}
title="Filter requests by method"
>
<IconFilter size={16} strokeWidth={1.5} />
<span className="filter-summary">
{activeFilters.length === Object.keys(filters).length ? 'All' : `${activeFilters.length}/${Object.keys(filters).length}`}
</span>
<IconChevronDown size={14} strokeWidth={1.5} />
</button>
{isOpen && (
<div className={`filter-dropdown-menu right`}>
<div className="filter-dropdown-header">
<span>Filter by Method</span>
<button
className="filter-toggle-all"
onClick={() => onToggleAll(!allFiltersEnabled)}
>
{allFiltersEnabled ? 'Hide All' : 'Show All'}
</button>
</div>
<div className="filter-dropdown-options">
{Object.keys(filters).map(method => (
<label key={method} className="filter-option">
<input
type="checkbox"
checked={filters[method]}
onChange={(e) => onFilterToggle(method, e.target.checked)}
/>
<div className="filter-option-content">
<MethodBadge method={method} />
<span className="filter-option-label">{method}</span>
<span className="filter-option-count">({requestCounts[method] || 0})</span>
</div>
</label>
))}
</div>
</div>
)}
</div>
);
};
const RequestRow = ({ request, isSelected, onClick }) => {
const { data } = request;
const { request: req, response: res, timestamp } = data;
const formatTime = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
};
const formatDuration = (duration) => {
if (!duration) return '-';
if (duration < 1000) return `${Math.round(duration)}ms`;
return `${(duration / 1000).toFixed(2)}s`;
};
const formatSize = (size) => {
if (!size) return '-';
if (size < 1024) return `${size}B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`;
return `${(size / (1024 * 1024)).toFixed(1)}MB`;
};
const getUrl = () => {
return req?.url || 'Unknown URL';
};
const getDomain = () => {
try {
const url = new URL(getUrl());
return url.hostname;
} catch {
return getUrl();
}
};
const getPath = () => {
try {
const url = new URL(getUrl());
return url.pathname + url.search;
} catch {
return getUrl();
}
};
return (
<div
className={`request-row ${isSelected ? 'selected' : ''}`}
onClick={onClick}
>
<div className="request-method">
<MethodBadge method={req?.method} />
</div>
<div className="request-status">
<StatusBadge status={res?.status} statusCode={res?.statusCode} />
</div>
<div className="request-domain" title={getDomain()}>
{getDomain()}
</div>
<div className="request-path" title={getPath()}>
{getPath()}
</div>
<div className="request-time">
{formatTime(timestamp)}
</div>
<div className="request-duration">
{formatDuration(res?.duration)}
</div>
<div className="request-size">
{formatSize(res?.size)}
</div>
</div>
);
};
const NetworkTab = () => {
const dispatch = useDispatch();
const { networkFilters, selectedRequest } = useSelector(state => state.logs);
const collections = useSelector(state => state.collections.collections);
const allRequests = useMemo(() => {
const requests = [];
collections.forEach(collection => {
if (collection.timeline) {
collection.timeline
.filter(entry => entry.type === 'request')
.forEach(entry => {
requests.push({
...entry,
collectionName: collection.name,
collectionUid: collection.uid
});
});
}
});
return requests.sort((a, b) => a.timestamp - b.timestamp);
}, [collections]);
const filteredRequests = useMemo(() => {
return allRequests.filter(request => {
const method = request.data?.request?.method?.toUpperCase() || 'GET';
return networkFilters[method];
});
}, [allRequests, networkFilters]);
const requestCounts = useMemo(() => {
return allRequests.reduce((counts, request) => {
const method = request.data?.request?.method?.toUpperCase() || 'GET';
counts[method] = (counts[method] || 0) + 1;
return counts;
}, {});
}, [allRequests]);
const handleFilterToggle = (method, enabled) => {
dispatch(updateNetworkFilter({ method, enabled }));
};
const handleToggleAllFilters = (enabled) => {
dispatch(toggleAllNetworkFilters(enabled));
};
const handleRequestClick = (request) => {
dispatch(setSelectedRequest(request));
};
return (
<StyledWrapper>
<div className="network-content">
{filteredRequests.length === 0 ? (
<div className="network-empty">
<IconNetwork size={48} strokeWidth={1} />
<p>No network requests</p>
<span>Requests will appear here as you make API calls</span>
</div>
) : (
<div className="requests-container">
<div className="requests-header">
<div className="header-method">Method</div>
<div className="header-status">Status</div>
<div className="header-domain">Domain</div>
<div className="header-path">Path</div>
<div className="header-time">Time</div>
<div className="header-duration">Duration</div>
<div className="header-size">Size</div>
</div>
<div className="requests-list">
{filteredRequests.map((request, index) => (
<RequestRow
key={`${request.collectionUid}-${request.itemUid}-${request.timestamp}-${index}`}
request={request}
isSelected={selectedRequest?.timestamp === request.timestamp && selectedRequest?.itemUid === request.itemUid}
onClick={() => handleRequestClick(request)}
/>
))}
</div>
</div>
)}
</div>
</StyledWrapper>
);
};
export default NetworkTab;