feat: added initial session recording changes

This commit is contained in:
ahrefabhi 2025-09-01 12:06:21 +05:30
parent 8b99ba0f9f
commit 63b9331c78
17 changed files with 4877 additions and 5 deletions

View File

@ -133,6 +133,7 @@
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"rehype-raw": "7.0.0",
"rrweb-player": "1.0.0-alpha.4",
"stream": "^0.0.2",
"style-loader": "1.3.0",
"styled-components": "^5.3.11",
@ -173,6 +174,7 @@
"@commitlint/config-conventional": "^16.2.4",
"@faker-js/faker": "9.3.0",
"@jest/globals": "^27.5.1",
"@rrweb/types": "2.0.0-alpha.18",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "13.4.0",
"@testing-library/user-event": "14.4.3",

View File

@ -295,3 +295,15 @@ export const MetricsExplorer = Loadable(
export const ApiMonitoring = Loadable(
() => import(/* webpackChunkName: "ApiMonitoring" */ 'pages/ApiMonitoring'),
);
export const SessionRecordings = Loadable(
() =>
import(/* webpackChunkName: "SessionRecordings" */ 'pages/SessionRecording'),
);
export const SessionRecordingsDetail = Loadable(
() =>
import(
/* webpackChunkName: "SessionRecordingsDetail" */ 'pages/SessionRecording/SessionDetail'
),
);

View File

@ -54,6 +54,8 @@ import {
WorkspaceAccessRestricted,
WorkspaceBlocked,
WorkspaceSuspended,
SessionRecordings,
SessionRecordingsDetail,
} from './pageComponents';
const routes: AppRoutes[] = [
@ -450,6 +452,20 @@ const routes: AppRoutes[] = [
key: 'METER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.SESSION_RECORDINGS,
exact: true,
component: SessionRecordings,
key: 'SESSION_RECORDINGS',
isPrivate: true,
},
{
path: ROUTES.SESSION_RECORDINGS_DETAIL,
exact: true,
component: SessionRecordingsDetail,
key: 'SESSION_RECORDINGS_DETAIL',
isPrivate: true,
},
{
path: ROUTES.METER_EXPLORER_VIEWS,
exact: true,

View File

@ -19,6 +19,8 @@ const ROUTES = {
GET_STARTED_AZURE_MONITORING: '/get-started/azure-monitoring',
USAGE_EXPLORER: '/usage-explorer',
APPLICATION: '/services',
SESSION_RECORDINGS: '/session-recordings',
SESSION_RECORDINGS_DETAIL: '/session-recordings/:sessionId',
ALL_DASHBOARD: '/dashboard',
DASHBOARD: '/dashboard/:dashboardId',
DASHBOARD_WIDGET: '/dashboard/:dashboardId/:widgetId',

View File

@ -24,7 +24,7 @@ import { AnimatePresence } from 'motion/react';
import * as motion from 'motion/react-client';
import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { DataSource } from 'types/common/queryBuilder';
@ -40,6 +40,9 @@ import HomeChecklist, { ChecklistItem } from './HomeChecklist/HomeChecklist';
import SavedViews from './SavedViews/SavedViews';
import Services from './Services/Services';
import StepsProgress from './StepsProgress/StepsProgress';
import events from './rows.json';
import { eventWithTime } from '@rrweb/types';
import RRWebPlayer from 'pages/SessionRecording/RRWebPlayer';
const homeInterval = 30 * 60 * 1000;
@ -168,6 +171,37 @@ export default function Home(): JSX.Element {
false,
);
const playerContainerRef = useRef(null);
// const [player, setPlayer] = useState<init | null>(null);
const [parsedEvents, setParsedEvents] = useState<eventWithTime[]>([]);
useEffect(() => {
if (events.rows.length > 0) {
// Initialize the rrweb player with events
const parsedEvents = events.rows.map((event) => {
return JSON.parse(event.data.body);
});
setParsedEvents(parsedEvents);
// const replayPlayer = new init({
// target: (playerContainerRef.current as unknown) as HTMLElement,
// props: {
// events: parsedEvents, // Pass the captured events to the player
// speed: 1, // Normal speed (can be adjusted)
// showDebug: false, // Optionally show debug info
// },
// });
// // Save the player instance for future use if needed
// setPlayer(replayPlayer);
// // Cleanup on unmount
// return () => {
// replayPlayer.destroy();
// };
}
}, [events]); // Re-run effect when events change
const processUserPreferences = (userPreferences: UserPreference[]): void => {
const checklistSkipped = Boolean(
userPreferences?.find(
@ -311,6 +345,9 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
<p>hello world</p>
<RRWebPlayer events={parsedEvents} options={{ autoPlay: false }} />
{/*
<div className="sticky-header">
{showBanner && (
<div className="home-container-banner">
@ -378,7 +415,6 @@ export default function Home(): JSX.Element {
}
/>
</div>
<div className="home-content">
<div className="home-left-content">
<DataSourceInfo
@ -764,7 +800,7 @@ export default function Home(): JSX.Element {
</>
)}
</div>
</div>
</div> */}
</div>
);
}

File diff suppressed because one or more lines are too long

View File

@ -31,6 +31,7 @@ import {
Unplug,
User,
UserPlus,
Video,
} from 'lucide-react';
import { SecondaryMenuItemKey, SidebarItem } from './sideNav.types';
@ -103,7 +104,6 @@ const menuItems: SidebarItem[] = [
icon: <HardDrive size={16} />,
itemKey: 'services',
},
{
key: ROUTES.LOGS,
label: 'Logs',
@ -274,6 +274,13 @@ export const defaultMoreMenuItems: SidebarItem[] = [
isBeta: true,
itemKey: 'meter-explorer',
},
{
key: ROUTES.SESSION_RECORDINGS,
label: 'Session Recordings',
icon: <Video size={16} />,
isEnabled: true,
itemKey: 'session-recordings',
},
{
key: ROUTES.MESSAGING_QUEUES_OVERVIEW,
label: 'Messaging Queues',

View File

@ -237,6 +237,8 @@ export const routesToSkip = [
ROUTES.METER,
ROUTES.METER_EXPLORER_VIEWS,
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.SESSION_RECORDINGS,
ROUTES.SESSION_RECORDINGS_DETAIL,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@ -0,0 +1,82 @@
# Session Recordings Page
A minimal, focused page that displays session recordings with just session names and play buttons.
## Features
- **Simple Session List**: Clean table showing only session names and play buttons
- **Direct Access**: Click play button or row to open session recording
- **Minimal UI**: No filters, search, or extra information - just the essentials
- **Responsive Design**: Adapts to different screen sizes
- **Dark/Light Mode Support**: Follows the app's theme system
## Components
### Main Component
- `index.tsx` - Main session recordings page component
### Supporting Files
- `types.ts` - TypeScript interfaces for session recording data
- `styles.scss` - Minimal styling with theme support
- `README.md` - This documentation file
## Data Structure
Each session recording includes:
- Basic identification (ID, session ID)
- User information (name, user agent)
- Timing details (start time, duration)
- Geographic data (country, city)
- Technical details (device, browser, OS)
- Status information (completion status, error flags)
- Recording URL for playback
## Table Columns
1. **Session Name** - Session identifier
2. **Actions** - Play button to open session recording
## Usage
The page automatically loads with mock data for demonstration. In production, replace the mock data with actual API calls to fetch session recordings.
### Table Interaction
- Click on any row to open the session recording
- Use the play button in the Actions column for quick access
- Sort by session name by clicking the column header
- Navigate through pages using the pagination controls
## Styling
The page uses CSS custom properties for theming:
- Dark mode: Uses `--bg-ink-*` and `--bg-slate-*` color variables
- Light mode: Uses `--bg-vanilla-*` color variables
- Accent colors: Uses `--bg-sakura-*` for primary actions and highlights
## Responsive Behavior
- **Desktop**: Clean table layout with proper spacing
- **Tablet**: Responsive table with maintained readability
- **Mobile**: Single-column layout with touch-friendly buttons
## Design Philosophy
The UI follows an extremely minimalist approach:
- Only essential information displayed
- No visual clutter or unnecessary features
- Focus on quick access to session recordings
- Clean, readable table layout
- Consistent with the app's design system
## Future Enhancements
- Session count display
- Basic sorting options
- Export functionality
- Real-time updates for active sessions

View File

@ -0,0 +1,80 @@
import React, { useEffect, useRef } from 'react';
import Player, { RRwebPlayerOptions } from 'rrweb-player';
import { eventWithTime } from '@rrweb/types';
import 'rrweb-player/dist/style.css'; // Import the styles for the player
interface RRWebPlayerProps {
events: eventWithTime[];
options?: Partial<RRwebPlayerOptions['props']>;
className?: string;
style?: React.CSSProperties;
}
const RRWebPlayer: React.FC<RRWebPlayerProps> = ({
events,
options = {},
className = '',
style = {},
}) => {
const playerRef = useRef<HTMLDivElement>(null);
const playerInstanceRef = useRef<any>(null);
useEffect(() => {
if (!playerRef.current || !events || events.length === 0) {
return;
}
// Clean up previous instance if it exists
if (playerInstanceRef.current) {
// Remove the previous player element
if (playerRef.current) {
playerRef.current.innerHTML = '';
}
}
// Create new player instance using the imported Player
playerInstanceRef.current = new Player({
target: playerRef.current as HTMLElement,
props: {
events,
...options,
},
});
// Cleanup function
return () => {
if (playerInstanceRef.current && playerRef.current) {
playerRef.current.innerHTML = '';
playerInstanceRef.current = null;
}
};
}, [events, options]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (playerInstanceRef.current && playerRef.current) {
playerRef.current.innerHTML = '';
playerInstanceRef.current = null;
}
};
}, []);
if (!events || events.length === 0) {
return (
<div className={`rrweb-player-empty ${className}`} style={style}>
<p>No session events available</p>
</div>
);
}
return (
<div
ref={playerRef}
className={`rrweb-player-container ${className}`}
style={style}
/>
);
};
export default RRWebPlayer;

View File

@ -0,0 +1,129 @@
import * as Sentry from '@sentry/react';
import { Button, Typography } from 'antd';
import { ArrowLeft } from 'lucide-react';
import { useParams, useHistory } from 'react-router-dom';
import { useQuery } from 'react-query';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { getAttributesValues } from 'api/queryBuilder/getAttributesValues';
import { DataSource } from 'types/common/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { SessionRecording } from '../types';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES, initialQueriesMap } from 'constants/queryBuilder';
import RRWebPlayer from '../RRWebPlayer';
import React from 'react';
import './styles.scss';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
const { Title, Text, Paragraph } = Typography;
export default function SessionDetail(): JSX.Element {
const { stagedQuery } = useQueryBuilder();
const { sessionId } = useParams<{ sessionId: string }>();
const history = useHistory();
// Fetch session-related logs data using useGetQueryRange
const {
data: sessionLogsData,
isLoading: isSessionLogsLoading,
} = useGetQueryRange(
{
query: stagedQuery || initialQueriesMap.logs,
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: '3d',
params: {
dataSource: DataSource.LOGS,
},
formatForWeb: false,
},
ENTITY_VERSION_V5,
{
queryKey: ['sessionLogs', sessionId],
enabled: !!sessionId && !!stagedQuery,
},
);
console.log({ sessionLogsData });
// Extract body fields from session logs data
const sessionEvents = React.useMemo(() => {
if (!sessionLogsData?.payload?.data?.newResult?.data?.result?.[0]?.list) {
return [];
}
return sessionLogsData.payload.data.newResult.data.result[0].list
.map((row: any) => {
// Try to extract body field from different possible locations
const body =
row.data?.body || row.data?.message || row.data?.log || row.data;
// If body is a string, try to parse it as JSON
if (typeof body === 'string') {
try {
return JSON.parse(body);
} catch {
// If parsing fails, return the string as is
return body;
}
}
return body;
})
.filter(Boolean); // Remove any undefined/null values
}, [sessionLogsData]);
const handleBack = (): void => {
history.push(ROUTES.SESSION_RECORDINGS);
};
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="session-detail-page">
<div className="page-header">
<div className="header-content">
<Button
type="text"
icon={<ArrowLeft size={16} />}
onClick={handleBack}
className="back-button"
>
Back to Sessions
</Button>
<Title level={2} className="page-title">
Session Recording: {sessionId}
</Title>
<Text type="secondary" className="page-description">
Detailed view of session recording and metadata
</Text>
</div>
</div>
<div className="page-content">
{/* Display RRWebPlayer with session events */}
{isSessionLogsLoading ? (
<div>Loading session logs...</div>
) : sessionEvents.length > 0 ? (
<div>
<h3>Session Recording Player</h3>
<div style={{ marginBottom: '16px' }}>
<strong>Total Events: {sessionEvents.length}</strong>
</div>
<RRWebPlayer
events={sessionEvents}
options={{
autoPlay: false,
}}
/>
</div>
) : (
<div>No session events available</div>
)}
</div>
</div>
</Sentry.ErrorBoundary>
);
}

View File

@ -0,0 +1,306 @@
.session-detail-page {
height: 100%;
background: var(--bg-ink-400);
padding: 0;
.page-header {
background: var(--bg-ink-500);
border-bottom: 1px solid var(--bg-slate-400);
padding: 24px 24px 16px;
margin-bottom: 0;
.header-content {
.back-button {
margin-bottom: 16px;
color: var(--bg-vanilla-400);
padding: 0;
height: auto;
font-size: 14px;
&:hover {
color: var(--bg-vanilla-100);
}
.anticon {
margin-right: 8px;
}
}
.page-title {
margin: 0 0 8px 0;
color: var(--bg-vanilla-100);
font-weight: 600;
font-size: 24px;
line-height: 32px;
}
.page-description {
font-size: 14px;
line-height: 20px;
color: var(--bg-vanilla-400);
margin: 0;
}
}
}
.page-content {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
.content-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 16px;
.overview-card,
.user-card,
.device-card,
.player-card,
.logs-card {
background: var(--bg-ink-500);
border: 1px solid var(--bg-slate-400);
border-radius: 6px;
.ant-card-head {
background: var(--bg-ink-400);
border-bottom: 1px solid var(--bg-slate-400);
.ant-card-head-title {
color: var(--bg-vanilla-100);
font-weight: 600;
font-size: 16px;
line-height: 24px;
}
}
.ant-card-body {
padding: 16px;
}
.ant-descriptions {
.ant-descriptions-item-label {
color: var(--bg-vanilla-400);
font-weight: 500;
font-size: 12px;
line-height: 16px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ant-descriptions-item-content {
color: var(--bg-vanilla-100);
font-size: 14px;
line-height: 20px;
}
}
.user-agent-text {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
}
.logs-content {
.logs-count {
display: block;
margin-top: 8px;
font-size: 14px;
}
.ant-typography {
margin-bottom: 8px;
}
}
line-height: 16px;
word-break: break-all;
max-width: 100%;
display: block;
}
.ant-tag {
font-size: 12px;
line-height: 16px;
padding: 2px 8px;
border-radius: 4px;
}
.ant-space {
.anticon {
color: var(--bg-vanilla-400);
flex-shrink: 0;
}
}
}
.player-card {
grid-column: span 2;
.player-content {
.player-description {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
margin-bottom: 24px;
text-align: center;
}
.player-actions {
display: flex;
justify-content: center;
.play-button-large {
background: var(--bg-sakura-500);
border-color: var(--bg-sakura-500);
color: var(--bg-ink-500);
height: 48px;
padding: 0 32px;
font-size: 16px;
font-weight: 500;
border-radius: 8px;
&:hover {
background: var(--bg-sakura-600);
border-color: var(--bg-sakura-600);
}
.anticon {
margin-right: 8px;
}
}
}
}
}
}
}
// Light mode overrides
.lightMode {
.session-detail-page {
background: var(--bg-vanilla-100);
.page-header {
background: var(--bg-vanilla-200);
border-bottom: 1px solid var(--bg-slate-300);
.header-content {
.back-button {
color: var(--bg-ink-400);
&:hover {
color: var(--bg-ink-500);
}
}
.page-title {
color: var(--bg-ink-500);
}
.page-description {
color: var(--bg-ink-400);
}
}
}
.page-content {
.content-grid {
.overview-card,
.user-card,
.device-card,
.player-card {
background: var(--bg-vanilla-200);
border: 1px solid var(--bg-slate-300);
.ant-card-head {
background: var(--bg-vanilla-300);
border-bottom: 1px solid var(--bg-slate-300);
.ant-card-head-title {
color: var(--bg-ink-500);
}
}
.ant-descriptions {
.ant-descriptions-item-label {
color: var(--bg-ink-400);
}
.ant-descriptions-item-content {
color: var(--bg-ink-500);
}
}
.ant-space {
.anticon {
color: var(--bg-ink-400);
}
}
}
.player-card {
.player-content {
.player-description {
color: var(--bg-ink-400);
}
}
}
}
}
}
}
// Responsive design
@media (max-width: 768px) {
.session-detail-page {
.page-content {
padding: 16px;
.content-grid {
grid-template-columns: 1fr;
gap: 12px;
.player-card {
grid-column: span 1;
}
}
}
}
}
@media (max-width: 480px) {
.session-detail-page {
.page-header {
padding: 16px 16px 12px;
.header-content {
.page-title {
font-size: 20px;
line-height: 28px;
}
}
}
.page-content {
padding: 12px;
.content-grid {
gap: 12px;
.overview-card,
.user-card,
.device-card,
.player-card {
.ant-card-body {
padding: 12px;
}
.ant-descriptions {
.ant-descriptions-item {
margin-bottom: 8px;
}
}
}
}
}
}
}

View File

@ -0,0 +1,166 @@
import * as Sentry from '@sentry/react';
import { Button, Card, Typography } from 'antd';
import { PlayCircle } from 'lucide-react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizeTable } from 'components/ResizeTable';
import { getAttributesValues } from 'api/queryBuilder/getAttributesValues';
import { DataSource } from 'types/common/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { SessionRecording } from './types';
import { useMemo } from 'react';
import './styles.scss';
const { Title, Text } = Typography;
export default function SessionRecordings(): JSX.Element {
const history = useHistory();
// Use React Query to fetch session attributes
const { data: sessionAttributes, isLoading, error } = useQuery({
queryKey: ['sessionAttributes', 'rum.sessionId'],
queryFn: () =>
getAttributesValues({
aggregateOperator: 'noop',
dataSource: DataSource.LOGS,
aggregateAttribute: '',
attributeKey: 'rum.sessionId',
searchText: '',
filterAttributeKeyDataType: DataTypes.String,
tagType: 'resource',
}),
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
});
const getQueryParams = (sessionId: string) => {
const compositeQuery = `compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522dataSource%2522%253A%2522logs%2522%252C%2522queryName%2522%253A%2522A%2522%252C%2522aggregateOperator%2522%253A%2522count%2522%252C%2522aggregateAttribute%2522%253A%257B%2522id%2522%253A%2522----%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522key%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%257D%252C%2522timeAggregation%2522%253A%2522rate%2522%252C%2522spaceAggregation%2522%253A%2522sum%2522%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522rum.sessionId%2520%253D%2520%27${sessionId}%27%2520%2522%257D%252C%2522aggregations%2522%253A%255B%257B%2522expression%2522%253A%2522count%28%29%2520%2522%257D%255D%252C%2522functions%2522%253A%255B%255D%252C%2522filters%2522%253A%257B%2522items%2522%253A%255B%255D%252C%2522op%2522%253A%2522AND%2522%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522stepInterval%2522%253Anull%252C%2522having%2522%253A%257B%2522expression%2522%253A%2522%2522%257D%252C%2522limit%2522%253Anull%252C%2522orderBy%2522%253A%255B%255D%252C%2522groupBy%2522%253A%255B%255D%252C%2522legend%2522%253A%2522%2522%252C%2522reduceTo%2522%253A%2522avg%2522%252C%2522source%2522%253A%2522%2522%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%252C%2522promql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522query%2522%253A%2522%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%257D%255D%252C%2522clickhouse_sql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%252C%2522query%2522%253A%2522%2522%257D%255D%252C%2522id%2522%253A%25226a102b3b-cb6b-4409-9e57-317f9ecb941b%2522%257D&options=%7B%22selectColumns%22%3A%5B%7B%22name%22%3A%22timestamp%22%2C%22signal%22%3A%22logs%22%2C%22fieldContext%22%3A%22log%22%2C%22fieldDataType%22%3A%22%22%2C%22isIndexed%22%3Afalse%7D%2C%7B%22name%22%3A%22body%22%2C%22signal%22%3A%22logs%22%2C%22fieldContext%22%3A%22log%22%2C%22fieldDataType%22%3A%22%22%2C%22isIndexed%22%3Afalse%7D%5D%2C%22maxLines%22%3A2%2C%22format%22%3A%22raw%22%2C%22fontSize%22%3A%22small%22%7D`;
return compositeQuery;
};
// Transform API response to table data
const sessionRecordings: SessionRecording[] = useMemo(() => {
if (
sessionAttributes?.statusCode === 200 &&
sessionAttributes.payload?.stringAttributeValues
) {
return sessionAttributes.payload.stringAttributeValues.map(
(sessionId: string, index: number) => ({
id: String(index + 1),
sessionId,
userName: 'anonymous',
userAgent: 'Mozilla/5.0 (Unknown)',
startTime: new Date().toISOString(), // You can extract timestamp from sessionId if available
duration: 0,
pageViews: 0,
country: 'Unknown',
city: 'Unknown',
device: 'Unknown',
browser: 'Unknown',
os: 'Unknown',
status: 'completed' as const,
hasErrors: false,
recordingUrl: `/session/${sessionId}?${getQueryParams(sessionId)}`,
}),
);
}
return [];
}, [sessionAttributes]);
// Log the response for debugging
if (sessionAttributes && sessionAttributes.statusCode === 200) {
console.log('Session attributes fetched:', sessionAttributes.payload);
}
if (error) {
console.error('Error fetching session attributes:', error);
}
const handleSessionClick = (record: SessionRecording): void => {
history.push(
`/session-recordings/${record.sessionId}?${getQueryParams(
record.sessionId,
)}`,
);
};
const handlePlayClick = (
e: React.MouseEvent,
record: SessionRecording,
): void => {
e.stopPropagation();
history.push(
`/session-recordings/${record.sessionId}?${getQueryParams(
record.sessionId,
)}`,
);
};
const columns = [
{
title: 'Session Name',
dataIndex: 'sessionId',
key: 'sessionId',
width: 200,
render: (sessionId: string) => <Text strong>{sessionId}</Text>,
},
{
title: 'Actions',
key: 'actions',
width: 100,
render: (_: any, record: SessionRecording) => (
<Button
type="primary"
size="small"
icon={<PlayCircle size={14} />}
onClick={(e) => handlePlayClick(e, record)}
className="play-button"
>
Play
</Button>
),
},
];
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="session-recordings-page">
<div className="page-header">
<div className="header-content">
<Title level={2} className="page-title">
Session Recordings
</Title>
<Text type="secondary" className="page-description">
Click play to view session recordings
</Text>
</div>
</div>
<div className="page-content">
<Card className="table-card">
<ResizeTable
columns={columns}
dataSource={sessionRecordings}
rowKey="id"
loading={isLoading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} of ${total} recordings`,
}}
className="session-recordings-table"
onRow={(record) => ({
onClick: () => handleSessionClick(record),
className: 'clickable-row',
})}
/>
</Card>
</div>
</div>
</Sentry.ErrorBoundary>
);
}

View File

@ -0,0 +1,221 @@
.session-recordings-page {
height: 100%;
background: var(--bg-ink-400);
padding: 0;
.page-header {
background: var(--bg-ink-500);
border-bottom: 1px solid var(--bg-slate-400);
padding: 24px 24px 16px;
margin-bottom: 0;
.header-content {
.page-title {
margin: 0 0 8px 0;
color: var(--bg-vanilla-100);
font-weight: 600;
font-size: 24px;
line-height: 32px;
}
.page-description {
font-size: 14px;
line-height: 20px;
color: var(--bg-vanilla-400);
margin: 0;
}
}
}
.page-content {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
.table-card {
background: var(--bg-ink-500);
border: 1px solid var(--bg-slate-400);
border-radius: 6px;
flex: 1;
.ant-card-body {
padding: 16px;
}
.session-recordings-table {
.ant-table {
background: transparent;
.ant-table-thead > tr > th {
background: var(--bg-ink-400);
border-bottom: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-300);
font-weight: 500;
font-size: 12px;
line-height: 16px;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 12px 16px;
}
.ant-table-tbody > tr > td {
background: transparent;
border-bottom: 1px solid var(--bg-slate-400);
padding: 16px;
color: var(--bg-vanilla-100);
}
.ant-table-tbody > tr:hover > td {
background: var(--bg-ink-400);
}
.clickable-row {
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: var(--bg-ink-400);
}
}
}
.ant-pagination {
margin: 16px 0 0 0;
text-align: right;
.ant-pagination-item,
.ant-pagination-prev,
.ant-pagination-next {
background: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-400);
&:hover {
border-color: var(--bg-sakura-500);
color: var(--bg-vanilla-100);
}
&.ant-pagination-item-active {
background: var(--bg-sakura-500);
border-color: var(--bg-sakura-500);
color: var(--bg-ink-500);
}
}
.ant-pagination-options {
.ant-select {
.ant-select-selector {
background: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
}
}
}
}
}
}
}
}
// Play button styles
.play-button {
background: var(--bg-sakura-500);
border-color: var(--bg-sakura-500);
color: var(--bg-ink-500);
&:hover {
background: var(--bg-sakura-600);
border-color: var(--bg-sakura-600);
}
.anticon {
margin-right: 4px;
}
}
// Light mode overrides
.lightMode {
.session-recordings-page {
background: var(--bg-vanilla-100);
.page-header {
background: var(--bg-vanilla-200);
border-bottom: 1px solid var(--bg-slate-300);
.header-content {
.page-title {
color: var(--bg-ink-500);
}
.page-description {
color: var(--bg-ink-400);
}
}
}
.page-content {
.table-card {
background: var(--bg-vanilla-200);
border: 1px solid var(--bg-slate-300);
.session-recordings-table {
.ant-table {
.ant-table-thead > tr > th {
background: var(--bg-vanilla-300);
border-bottom: 1px solid var(--bg-slate-300);
color: var(--bg-ink-400);
}
.ant-table-tbody > tr > td {
border-bottom: 1px solid var(--bg-slate-300);
color: var(--bg-ink-500);
}
.ant-table-tbody > tr:hover > td {
background: var(--bg-vanilla-300);
}
.clickable-row:hover {
background: var(--bg-vanilla-300);
}
}
.ant-pagination {
.ant-pagination-item,
.ant-pagination-prev,
.ant-pagination-next {
background: var(--bg-vanilla-300);
border: 1px solid var(--bg-slate-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-sakura-500);
color: var(--bg-ink-500);
}
}
.ant-pagination-options {
.ant-select {
.ant-select-selector {
background: var(--bg-vanilla-300);
border: 1px solid var(--bg-slate-300);
color: var(--bg-ink-500);
}
}
}
}
}
}
}
}
}
// Responsive design
@media (max-width: 768px) {
.session-recordings-page {
.page-content {
padding: 16px;
}
}
}

View File

@ -0,0 +1,17 @@
export interface SessionRecording {
id: string;
sessionId: string;
userName: string;
userAgent: string;
startTime: string;
duration: number; // seconds
pageViews: number;
country: string;
city: string;
device: string;
browser: string;
os: string;
status: 'completed' | 'in_progress' | 'failed';
hasErrors: boolean;
recordingUrl: string;
}

View File

@ -108,6 +108,12 @@ const config = {
},
{
test: /\.css$/,
include: /node_modules/,
use: [styleLoader, cssLoader],
},
{
test: /\.css$/,
exclude: /node_modules/,
use: [
styleLoader,
{

View File

@ -4046,6 +4046,11 @@
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.20.0.tgz#03554155b45d8b529adf635b2f6ad1165d70d8b4"
integrity sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==
"@rrweb/types@2.0.0-alpha.18", "@rrweb/types@^2.0.0-alpha.4":
version "2.0.0-alpha.18"
resolved "https://registry.yarnpkg.com/@rrweb/types/-/types-2.0.0-alpha.18.tgz#e1d9af844cebbf30a2be8808f6cf64f5df3e7f50"
integrity sha512-iMH3amHthJZ9x3gGmBPmdfim7wLGygC2GciIkw2A6SO8giSn8PHYtRT8OKNH4V+k3SZ6RSnYHcTQxBA7pSWZ3Q==
"@sentry-internal/browser-utils@8.41.0":
version "8.41.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.41.0.tgz#9dc30a8c88aa6e1e542e5acae29ceabd1b377cc4"
@ -4431,6 +4436,11 @@
resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz"
integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==
"@tsconfig/svelte@^1.0.0":
version "1.0.13"
resolved "https://registry.yarnpkg.com/@tsconfig/svelte/-/svelte-1.0.13.tgz#2fa34376627192c0d643ce54964915e2bd3a58e4"
integrity sha512-5lYJP45Xllo4yE/RUBccBT32eBlRDbqN8r1/MIvQbKxW3aFqaYPCNgm8D5V20X4ShHcwvYWNlKg3liDh1MlBoA==
"@tweenjs/tween.js@18 - 19", "@tweenjs/tween.js@19":
version "19.0.0"
resolved "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-19.0.0.tgz"
@ -4561,6 +4571,11 @@
tapable "^2.0.0"
webpack "^5.1.0"
"@types/css-font-loading-module@0.0.7":
version "0.0.7"
resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz#2f98ede46acc0975de85c0b7b0ebe06041d24601"
integrity sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==
"@types/d3-array@3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.3.tgz#87d990bf504d14ad6b16766979d04e943c046dac"
@ -5724,6 +5739,11 @@
resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz"
integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==
"@xstate/fsm@^1.4.0":
version "1.6.5"
resolved "https://registry.yarnpkg.com/@xstate/fsm/-/fsm-1.6.5.tgz#f599e301997ad7e3c572a0b1ff0696898081bea5"
integrity sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==
"@xstate/react@^3.0.0":
version "3.2.2"
resolved "https://registry.npmjs.org/@xstate/react/-/react-3.2.2.tgz"
@ -6698,6 +6718,11 @@ balanced-match@^1.0.0:
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-arraybuffer@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
@ -9397,7 +9422,7 @@ fb-watchman@^2.0.0:
dependencies:
bser "2.1.1"
fflate@^0.4.8:
fflate@^0.4.4, fflate@^0.4.8:
version "0.4.8"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae"
integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==
@ -13155,6 +13180,11 @@ minipass@^4.2.4:
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c"
integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==
mitt@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1"
integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==
mkdirp@^0.5.6:
version "0.5.6"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
@ -15989,6 +16019,40 @@ robust-predicates@^3.0.2:
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
rrdom@^0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/rrdom/-/rrdom-0.1.7.tgz#f2f49bfd01b59291bb7b0d981371a5e02a18e2aa"
integrity sha512-ZLd8f14z9pUy2Hk9y636cNv5Y2BMnNEY99wxzW9tD2BLDfe1xFxtLjB4q/xCBYo6HRe0wofzKzjm4JojmpBfFw==
dependencies:
rrweb-snapshot "^2.0.0-alpha.4"
rrweb-player@1.0.0-alpha.4:
version "1.0.0-alpha.4"
resolved "https://registry.yarnpkg.com/rrweb-player/-/rrweb-player-1.0.0-alpha.4.tgz#57576343aaff6c6fb266689fd5d63092be46967c"
integrity sha512-Wlmn9GZ5Fdqa37vd3TzsYdLl/JWEvXNUrLCrYpnOwEgmY409HwVIvvA5aIo7k582LoKgdRCsB87N+f0oWAR0Kg==
dependencies:
"@tsconfig/svelte" "^1.0.0"
rrweb "^2.0.0-alpha.4"
rrweb-snapshot@^2.0.0-alpha.4:
version "2.0.0-alpha.4"
resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.4.tgz#2801bf5946177b9d685a01661a62d9d2e958f174"
integrity sha512-KQ2OtPpXO5jLYqg1OnXS/Hf+EzqnZyP5A+XPqBCjYpj3XIje/Od4gdUwjbFo3cVuWq5Cw5Y1d3/xwgIS7/XpQQ==
rrweb@^2.0.0-alpha.4:
version "2.0.0-alpha.4"
resolved "https://registry.yarnpkg.com/rrweb/-/rrweb-2.0.0-alpha.4.tgz#3c7cf2f1bcf44f7a88dd3fad00ee8d6dd711f258"
integrity sha512-wEHUILbxDPcNwkM3m4qgPgXAiBJyqCbbOHyVoNEVBJzHszWEFYyTbrZqUdeb1EfmTRC2PsumCIkVcomJ/xcOzA==
dependencies:
"@rrweb/types" "^2.0.0-alpha.4"
"@types/css-font-loading-module" "0.0.7"
"@xstate/fsm" "^1.4.0"
base64-arraybuffer "^1.0.1"
fflate "^0.4.4"
mitt "^3.0.0"
rrdom "^0.1.7"
rrweb-snapshot "^2.0.0-alpha.4"
rtl-css-js@^1.14.0, rtl-css-js@^1.16.1:
version "1.16.1"
resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz"