Plex and Jellyfin widget (#713)

This commit is contained in:
Manuel
2023-02-15 22:12:49 +01:00
committed by GitHub
parent ca50cffe82
commit d157e986a1
20 changed files with 1129 additions and 236 deletions

View File

@@ -0,0 +1,26 @@
import { Avatar, DefaultMantineColor, useMantineTheme } from '@mantine/core';
export const AppAvatar = ({
iconUrl,
color,
}: {
iconUrl: string;
color?: DefaultMantineColor | undefined;
}) => {
const { colors, colorScheme } = useMantineTheme();
return (
<Avatar
src={iconUrl}
bg={colorScheme === 'dark' ? colors.gray[8] : colors.gray[2]}
size="sm"
radius="xl"
p={4}
styles={{
root: {
borderColor: color !== undefined ? colors[color] : undefined,
},
}}
/>
);
};

View File

@@ -75,6 +75,16 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png',
label: 'Readarr',
},
{
value: 'jellyfin',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png',
label: 'Jellyfin',
},
{
value: 'plex',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png',
label: 'Plex',
},
].filter((x) => Object.keys(integrationFieldProperties).includes(x.value));
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {

View File

@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { MediaServersResponseType } from '../../../types/api/media-server/response';
interface GetMediaServersParams {
enabled: boolean;
}
export const useGetMediaServers = ({ enabled }: GetMediaServersParams) =>
useQuery({
queryKey: ['media-servers'],
queryFn: async (): Promise<MediaServersResponseType> => {
const response = await fetch('/api/modules/media-server');
return response.json();
},
enabled,
refetchInterval: 10 * 1000,
});

View File

@@ -0,0 +1,227 @@
import { Jellyfin } from '@jellyfin/sdk';
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
import Consola from 'consola';
import { getCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { BaseItemKind, ProgramAudio } from '@jellyfin/sdk/lib/generated-client/models';
import { getConfig } from '../../../../tools/config/getConfig';
import { PlexClient } from '../../../../tools/server/sdk/plex/plexClient';
import { GenericMediaServer } from '../../../../types/api/media-server/media-server';
import { MediaServersResponseType } from '../../../../types/api/media-server/response';
import {
GenericCurrentlyPlaying,
GenericSessionInfo,
} from '../../../../types/api/media-server/session-info';
import { ConfigAppType } from '../../../../types/app';
const jellyfin = new Jellyfin({
clientInfo: {
name: 'Homarr',
version: '0.0.1',
},
deviceInfo: {
name: 'Homarr Jellyfin Widget',
id: 'homarr-jellyfin-widget',
},
});
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
const configName = getCookie('config-name', { req: request });
const config = getConfig(configName?.toString() ?? 'default');
const apps = config.apps.filter((app) =>
['jellyfin', 'plex'].includes(app.integration?.type ?? '')
);
const servers = await Promise.all(
apps.map(async (app): Promise<GenericMediaServer | undefined> => {
try {
return await handleServer(app);
} catch (error) {
Consola.error(
`failed to communicate with media server '${app.name}' (${app.id}): ${error}`
);
return {
serverAddress: app.url,
sessions: [],
success: false,
version: undefined,
type: undefined,
appId: app.id,
};
}
})
);
return response.status(200).json({
servers: servers.filter((server) => server !== undefined),
} as MediaServersResponseType);
};
const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | undefined> => {
switch (app.integration?.type) {
case 'jellyfin': {
const username = app.integration.properties.find((x) => x.field === 'username');
if (!username || !username.value) {
return {
appId: app.id,
serverAddress: app.url,
sessions: [],
type: 'jellyfin',
version: undefined,
success: false,
};
}
const password = app.integration.properties.find((x) => x.field === 'password');
if (!password || !password.value) {
return {
appId: app.id,
serverAddress: app.url,
sessions: [],
type: 'jellyfin',
version: undefined,
success: false,
};
}
const api = jellyfin.createApi(app.url);
const infoApi = await getSystemApi(api).getPublicSystemInfo();
await api.authenticateUserByName(username.value, password.value);
const sessionApi = await getSessionApi(api);
const sessions = await sessionApi.getSessions();
return {
type: 'jellyfin',
appId: app.id,
serverAddress: app.url,
version: infoApi.data.Version ?? undefined,
sessions: sessions.data.map(
(session): GenericSessionInfo => ({
id: session.Id ?? '?',
username: session.UserName ?? undefined,
sessionName: `${session.Client} (${session.DeviceName})`,
supportsMediaControl: session.SupportsMediaControl ?? false,
currentlyPlaying: session.NowPlayingItem
? {
name: session.NowPlayingItem.Name as string,
seasonName: session.NowPlayingItem.SeasonName as string,
albumName: session.NowPlayingItem.Album as string,
episodeCount: session.NowPlayingItem.EpisodeCount ?? undefined,
metadata: {
video:
session.NowPlayingItem &&
session.NowPlayingItem.Width &&
session.NowPlayingItem.Height
? {
videoCodec: undefined,
width: session.NowPlayingItem.Width ?? undefined,
height: session.NowPlayingItem.Height ?? undefined,
bitrate: undefined,
videoFrameRate: session.TranscodingInfo?.Framerate
? String(session.TranscodingInfo?.Framerate)
: undefined,
}
: undefined,
audio: session.TranscodingInfo
? {
audioChannels: session.TranscodingInfo.AudioChannels ?? undefined,
audioCodec: session.TranscodingInfo.AudioCodec ?? undefined,
}
: undefined,
transcoding: session.TranscodingInfo
? {
audioChannels: session.TranscodingInfo.AudioChannels ?? -1,
audioCodec: session.TranscodingInfo.AudioCodec ?? undefined,
container: session.TranscodingInfo.Container ?? undefined,
width: session.TranscodingInfo.Width ?? undefined,
height: session.TranscodingInfo.Height ?? undefined,
videoCodec: session.TranscodingInfo?.VideoCodec ?? undefined,
audioDecision: undefined,
context: undefined,
duration: undefined,
error: undefined,
sourceAudioCodec: undefined,
sourceVideoCodec: undefined,
timeStamp: undefined,
transcodeHwRequested: undefined,
videoDecision: undefined,
}
: undefined,
},
type: convertJellyfinType(session.NowPlayingItem.Type),
}
: undefined,
userProfilePicture: undefined,
})
),
success: true,
};
}
case 'plex': {
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey');
if (!apiKey || !apiKey.value) {
return {
serverAddress: app.url,
sessions: [],
type: 'plex',
appId: app.id,
version: undefined,
success: false,
};
}
const plexClient = new PlexClient(app.url, apiKey.value);
const sessions = await plexClient.getSessions();
return {
serverAddress: app.url,
sessions,
type: 'plex',
version: undefined,
appId: app.id,
success: true,
};
}
default: {
Consola.warn(
`media-server api entered a fallback case. This should normally not happen and must be reported. Cause: '${app.name}' (${app.id})`
);
return undefined;
}
}
};
const convertJellyfinType = (kind: BaseItemKind | undefined): GenericCurrentlyPlaying['type'] => {
switch (kind) {
case BaseItemKind.Audio:
case BaseItemKind.MusicVideo:
return 'audio';
case BaseItemKind.Episode:
case BaseItemKind.Video:
return 'video';
case BaseItemKind.Movie:
return 'movie';
case BaseItemKind.TvChannel:
case BaseItemKind.TvProgram:
case BaseItemKind.LiveTvChannel:
case BaseItemKind.LiveTvProgram:
return 'tv';
default:
return undefined;
}
};
export default async (request: NextApiRequest, response: NextApiResponse) => {
if (request.method === 'GET') {
return Get(request, response);
}
return response.status(405);
};

View File

@@ -0,0 +1,108 @@
import { Element, xml2js } from 'xml-js';
import {
GenericCurrentlyPlaying,
GenericSessionInfo,
} from '../../../../types/api/media-server/session-info';
export class PlexClient {
constructor(private readonly apiAddress: string, private readonly token: string) {}
async getSessions(): Promise<GenericSessionInfo[]> {
const response = await fetch(`${this.apiAddress}/status/sessions?X-Plex-Token=${this.token}`);
const body = await response.text();
// convert xml response to objects, as there is no JSON api
const data = xml2js(body);
// TODO: Investigate when there are no media containers
const mediaContainer = data.elements[0] as Element;
// no sessions are open or available
if (!mediaContainer.elements?.some((_) => true)) {
return [];
}
const videoElements = mediaContainer.elements as Element[];
const videos = videoElements
.map((videoElement): GenericSessionInfo | undefined => {
// extract the elements from the children
const userElement = this.findElement('User', videoElement.elements);
const playerElement = this.findElement('Player', videoElement.elements);
const mediaElement = this.findElement('Media', videoElement.elements);
const sessionElement = this.findElement('Session', videoElement.elements);
if (!userElement || !playerElement || !mediaElement || !sessionElement) {
return undefined;
}
const { videoCodec, videoFrameRate, audioCodec, audioChannels, height, width, bitrate } =
mediaElement;
const transcodingElement = this.findElement('TranscodeSession', videoElement.elements);
return {
id: sessionElement.id as string,
username: userElement.title as string,
userProfilePicture: userElement.thumb as string,
sessionName: `${playerElement.product} (${playerElement.title})`,
currentlyPlaying: {
name: videoElement.attributes?.title as string,
type: this.getCurrentlyPlayingType(videoElement.attributes?.type as string),
metadata: {
video: {
bitrate,
height,
videoCodec,
videoFrameRate,
width,
},
audio: {
audioChannels,
audioCodec,
},
transcoding:
transcodingElement === undefined
? undefined
: {
audioChannels: transcodingElement.audioChannels,
audioCodec: transcodingElement.audioCodec,
audioDecision: transcodingElement.audioDecision,
container: transcodingElement.container,
context: transcodingElement.context,
duration: transcodingElement.duration,
error: transcodingElement.error === 1,
height: transcodingElement.height,
sourceAudioCodec: transcodingElement.sourceAudioCodec,
sourceVideoCodec: transcodingElement.sourceVideoCodec,
timeStamp: transcodingElement.timeStamp,
transcodeHwRequested: transcodingElement.transcodeHwRequested === 1,
videoCodec: transcodingElement.videoCodec,
videoDecision: transcodingElement.videoDecision,
width: transcodingElement.width,
},
},
},
} as GenericSessionInfo;
})
.filter((x) => x !== undefined) as GenericSessionInfo[];
return videos;
}
private findElement(name: string, elements: Element[] | undefined) {
return elements?.find((x) => x.name === name)?.attributes;
}
private getCurrentlyPlayingType(type: string): GenericCurrentlyPlaying['type'] {
switch (type) {
case 'movie':
return 'movie';
case 'episode':
return 'video';
default:
return undefined;
}
}
}

View File

@@ -33,6 +33,7 @@ export const dashboardNamespaces = [
'modules/docker',
'modules/dashdot',
'modules/overseerr',
'modules/media-server',
'modules/common-media-cards',
'modules/video-stream',
];

View File

@@ -0,0 +1,34 @@
import { GenericSessionInfo } from './session-info';
export type GenericMediaServer = {
/**
* The type of the media server.
* Undefined indicates, that the type is either unsupported or recognizing went wrong
*/
type: 'jellyfin' | 'plex' | undefined;
/**
* The address of the server
*/
serverAddress: string;
/**
* The current version of the server
*/
version: string | undefined;
/**
* The active sessions on the server
*/
sessions: GenericSessionInfo[];
/**
* The app id of the used app
*/
appId: string;
/**
* Indicates, wether the communication was successfull or not
*/
success: boolean;
};

View File

@@ -0,0 +1,5 @@
import { GenericMediaServer } from './media-server';
export type MediaServersResponseType = {
servers: GenericMediaServer[];
};

View File

@@ -0,0 +1,46 @@
export type GenericSessionInfo = {
supportsMediaControl: boolean;
username: string | undefined;
id: string;
sessionName: string;
userProfilePicture: string | undefined;
currentlyPlaying: GenericCurrentlyPlaying | undefined;
};
export type GenericCurrentlyPlaying = {
name: string;
seasonName: string | undefined;
albumName: string | undefined;
episodeCount: number | undefined;
type: 'audio' | 'video' | 'tv' | 'movie' | undefined;
metadata: {
video: {
videoCodec: string | undefined;
videoFrameRate: string | undefined;
height: number | undefined;
width: number | undefined;
bitrate: number | undefined;
} | undefined;
audio: {
audioCodec: string | undefined;
audioChannels: number | undefined;
} | undefined;
transcoding: {
context: string | undefined;
sourceVideoCodec: string | undefined;
sourceAudioCodec: string | undefined;
videoDecision: string | undefined;
audioDecision: string | undefined;
container: string | undefined;
videoCodec: string | undefined;
audioCodec: string | undefined;
error: boolean | undefined;
duration: number | undefined;
audioChannels: number | undefined;
width: number | undefined;
height: number | undefined;
transcodeHwRequested: boolean | undefined;
timeStamp: number | undefined;
} | undefined;
};
};

View File

@@ -41,6 +41,8 @@ export type IntegrationType =
| 'deluge'
| 'qBittorrent'
| 'transmission'
| 'plex'
| 'jellyfin'
| 'nzbGet';
export type AppIntegrationType = {
@@ -79,6 +81,8 @@ export const integrationFieldProperties: {
nzbGet: ['username', 'password'],
qBittorrent: ['username', 'password'],
transmission: ['username', 'password'],
jellyfin: ['username', 'password'],
plex: ['apiKey'],
};
export type IntegrationFieldDefinitionType = {

View File

@@ -15,6 +15,7 @@ import { Serie, Datum, ResponsiveLine } from '@nivo/line';
import { IconDownload, IconUpload } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useEffect } from 'react';
import { AppAvatar } from '../../components/AppAvatar';
import { useConfigContext } from '../../config/provider';
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
import { useColorTheme } from '../../tools/color';
@@ -258,17 +259,3 @@ export default function TorrentNetworkTrafficTile({ widget }: TorrentNetworkTraf
</Stack>
);
}
const AppAvatar = ({ iconUrl }: { iconUrl: string }) => {
const { colors, colorScheme } = useMantineTheme();
return (
<Avatar
src={iconUrl}
bg={colorScheme === 'dark' ? colors.gray[8] : colors.gray[2]}
size="sm"
radius="xl"
p={4}
/>
);
};

View File

@@ -2,6 +2,7 @@ import calendar from './calendar/CalendarTile';
import dashdot from './dashDot/DashDotTile';
import date from './date/DateTile';
import torrentNetworkTraffic from './download-speed/TorrentNetworkTrafficTile';
import mediaServer from './media-server/MediaServerTile';
import rss from './rss/RssWidgetTile';
import torrent from './torrent/TorrentTile';
import usenet from './useNet/UseNetTile';
@@ -18,4 +19,5 @@ export default {
date,
rss,
'video-stream': videoStream,
'media-server': mediaServer,
};

View File

@@ -0,0 +1,128 @@
import { Card, Divider, Flex, Grid, Group, Text } from '@mantine/core';
import { IconDeviceMobile, IconId } from '@tabler/icons';
import { GenericSessionInfo } from '../../types/api/media-server/session-info';
export const DetailCollapseable = ({ session }: { session: GenericSessionInfo }) => {
let details: { title: string; metrics: { name: string; value: string | undefined }[] }[] = [];
if (session.currentlyPlaying) {
if (session.currentlyPlaying.metadata.video) {
details = [
...details,
{
title: 'Video',
metrics: [
{
name: 'Resolution',
value: `${session.currentlyPlaying.metadata.video.width}x${session.currentlyPlaying.metadata.video.height}`,
},
{
name: 'Framerate',
value: session.currentlyPlaying.metadata.video.videoFrameRate,
},
{
name: 'Codec',
value: session.currentlyPlaying.metadata.video.videoCodec,
},
{
name: 'Bitrate',
value: session.currentlyPlaying.metadata.video.bitrate
? String(session.currentlyPlaying.metadata.video.bitrate)
: undefined,
},
],
},
];
}
if (session.currentlyPlaying.metadata.audio) {
details = [
...details,
{
title: 'Audio',
metrics: [
{
name: 'Audio channels',
value: `${session.currentlyPlaying.metadata.audio.audioChannels}`,
},
{
name: 'Audio codec',
value: session.currentlyPlaying.metadata.audio.audioCodec,
},
],
},
];
}
if (session.currentlyPlaying.metadata.transcoding) {
details = [
...details,
{
title: 'Transcoding',
metrics: [
{
name: 'Resolution',
value: `${session.currentlyPlaying.metadata.transcoding.width}x${session.currentlyPlaying.metadata.transcoding.height}`,
},
{
name: 'Context',
value: session.currentlyPlaying.metadata.transcoding.context,
},
{
name: 'Hardware encoding requested',
value: session.currentlyPlaying.metadata.transcoding.transcodeHwRequested
? 'yes'
: 'no',
},
{
name: 'Source codec',
value:
session.currentlyPlaying.metadata.transcoding.sourceAudioCodec ||
session.currentlyPlaying.metadata.transcoding.sourceVideoCodec
? `${session.currentlyPlaying.metadata.transcoding.sourceVideoCodec} ${session.currentlyPlaying.metadata.transcoding.sourceAudioCodec}`
: undefined,
},
{
name: 'Target codec',
value: `${session.currentlyPlaying.metadata.transcoding.videoCodec} ${session.currentlyPlaying.metadata.transcoding.audioCodec}`,
},
],
},
];
}
}
return (
<Card>
<Flex justify="space-between" mb="xs">
<Group>
<IconId size={16} />
<Text>ID</Text>
</Group>
<Text>{session.id}</Text>
</Flex>
<Flex justify="space-between" mb="md">
<Group>
<IconDeviceMobile size={16} />
<Text>Device</Text>
</Group>
<Text>{session.sessionName}</Text>
</Flex>
{details.length > 0 && <Divider label="Stats for nerds" labelPosition="center" mt="lg" mb="sm" />}
<Grid>
{details.map((detail, index) => (
<Grid.Col xs={12} sm={6} key={index}>
<Text weight="bold">{detail.title}</Text>
{detail.metrics
.filter((x) => x.value !== undefined)
.map((metric, index2) => (
<Group position="apart" key={index2}>
<Text>{metric.name}</Text>
<Text>{metric.value}</Text>
</Group>
))}
</Grid.Col>
))}
</Grid>
</Card>
);
};

View File

@@ -0,0 +1,110 @@
import {
Avatar,
Center,
Group,
Loader,
ScrollArea,
Stack,
Table,
Text,
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconMovie } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { AppAvatar } from '../../components/AppAvatar';
import { useConfigContext } from '../../config/provider';
import { useGetMediaServers } from '../../hooks/widgets/media-servers/useGetMediaServers';
import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
import { TableRow } from './TableRow';
const definition = defineWidget({
id: 'media-server',
icon: IconMovie,
options: {},
component: MediaServerTile,
gridstack: {
minWidth: 3,
minHeight: 2,
maxWidth: 12,
maxHeight: 12,
},
});
export type MediaServerWidget = IWidget<(typeof definition)['id'], typeof definition>;
interface MediaServerWidgetProps {
widget: MediaServerWidget;
}
function MediaServerTile({ widget }: MediaServerWidgetProps) {
const { t } = useTranslation('modules/media-server');
const { config } = useConfigContext();
const { data, isError } = useGetMediaServers({
enabled: config !== undefined,
});
if (isError) {
return (
<Center>
<Stack align="center">
<IconAlertTriangle />
<Title order={6}>{t('card.errors.general.title')}</Title>
<Text>{t('card.errors.general.text')}</Text>
</Stack>
</Center>
);
}
if (!data) {
<Center h="100%">
<Loader />
</Center>;
}
return (
<Stack h="100%">
<ScrollArea offsetScrollbars>
<Table highlightOnHover striped>
<thead>
<tr>
<th>{t('card.table.header.session')}</th>
<th>{t('card.table.header.user')}</th>
<th>{t('card.table.header.currentlyPlaying')}</th>
</tr>
</thead>
<tbody>
{data?.servers.map((server) => {
const app = config?.apps.find((x) => x.id === server.appId);
return server.sessions.map((session, index) => (
<TableRow session={session} app={app} key={index} />
));
})}
</tbody>
</Table>
</ScrollArea>
<Group position="right" mt="auto">
<Avatar.Group>
{data?.servers.map((server) => {
const app = config?.apps.find((x) => x.id === server.appId);
if (!app) {
return null;
}
return (
<AppAvatar
iconUrl={app.appearance.iconUrl}
color={server.success === true ? undefined : 'red'}
/>
);
})}
</Avatar.Group>
</Group>
</Stack>
);
}
export default definition;

View File

@@ -0,0 +1,50 @@
import { Flex, Group, Stack, Text } from '@mantine/core';
import {
IconDeviceTv,
IconHeadphones,
IconQuestionMark,
IconVideo,
TablerIcon,
} from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { GenericSessionInfo } from '../../types/api/media-server/session-info';
export const NowPlayingDisplay = ({ session }: { session: GenericSessionInfo }) => {
const { t } = useTranslation();
if (!session.currentlyPlaying) {
return null;
}
const Icon = (): TablerIcon => {
switch (session.currentlyPlaying?.type) {
case 'audio':
return IconHeadphones;
case 'tv':
return IconDeviceTv;
case 'video':
return IconVideo;
default:
return IconQuestionMark;
}
};
const Test = Icon();
return (
<Flex wrap="nowrap" gap="sm" align="center">
<Test size={16} />
<Stack spacing={0}>
<Text lineClamp={1}>{session.currentlyPlaying.name}</Text>
{session.currentlyPlaying.albumName ? (
<Text lineClamp={1} color="dimmed" size="xs">{session.currentlyPlaying.albumName}</Text>
) : (
session.currentlyPlaying.seasonName && (
<Text lineClamp={1} color="dimmed" size="xs">{session.currentlyPlaying.seasonName}</Text>
)
)}
</Stack>
</Flex>
);
};

View File

@@ -0,0 +1,73 @@
import {
Avatar,
Card,
Collapse,
createStyles,
Flex,
Grid,
Group,
Stack,
Text,
Title,
} from '@mantine/core';
import { useState } from 'react';
import { AppAvatar } from '../../components/AppAvatar';
import { GenericSessionInfo } from '../../types/api/media-server/session-info';
import { AppType } from '../../types/app';
import { DetailCollapseable } from './DetailCollapseable';
import { NowPlayingDisplay } from './NowPlayingDisplay';
interface TableRowProps {
session: GenericSessionInfo;
app: AppType | undefined;
}
export const TableRow = ({ session, app }: TableRowProps) => {
const [collapseOpen, setCollapseOpen] = useState(false);
const hasUserThumb = session.userProfilePicture !== undefined;
const { classes } = useStyles();
return (
<>
<tr className={classes.dataRow} onClick={() => setCollapseOpen(!collapseOpen)}>
<td>
<Flex wrap="nowrap" gap="xs">
{app?.appearance.iconUrl && <AppAvatar iconUrl={app.appearance.iconUrl} />}
<Text lineClamp={1}>{session.sessionName}</Text>
</Flex>
</td>
<td>
<Flex wrap="nowrap" gap="sm">
{hasUserThumb ? (
<Avatar src={session.userProfilePicture} size="sm" />
) : (
<Avatar src={null} alt={session.username} size="sm">
{session.username?.at(0)?.toUpperCase()}
</Avatar>
)}
<Text>{session.username}</Text>
</Flex>
</td>
<td>
<NowPlayingDisplay session={session} />
</td>
</tr>
<tr>
<td className={classes.collapseTableDataCell} colSpan={3}>
<Collapse in={collapseOpen} w="100%">
<DetailCollapseable session={session} />
</Collapse>
</td>
</tr>
</>
);
};
const useStyles = createStyles(() => ({
dataRow: {
cursor: 'pointer',
},
collapseTableDataCell: {
border: 'none !important',
padding: '0 !important',
},
}));