From 74a3dab699c8afe5d58f07b3fefb4c61f2cc73ac Mon Sep 17 00:00:00 2001 From: Thegan Govender Date: Thu, 29 Feb 2024 11:03:35 +0200 Subject: [PATCH] Fix sorting in last user activity view added ip-info to the activity tables added google maps integration refactored ip-info element to be more reusable --- .../063_fs_last_user_activity_fix_1.js | 92 ++++ public/locales/en/translation.json | 12 +- .../components/activity/activity-table.jsx | 431 +++++++++++------- src/pages/components/ip-info.jsx | 173 ++++--- .../components/sessions/session-card.jsx | 58 +-- 5 files changed, 494 insertions(+), 272 deletions(-) create mode 100644 backend/migrations/063_fs_last_user_activity_fix_1.js diff --git a/backend/migrations/063_fs_last_user_activity_fix_1.js b/backend/migrations/063_fs_last_user_activity_fix_1.js new file mode 100644 index 0000000..35f8095 --- /dev/null +++ b/backend/migrations/063_fs_last_user_activity_fix_1.js @@ -0,0 +1,92 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP FUNCTION IF EXISTS public.fs_last_user_activity(text); + + CREATE OR REPLACE FUNCTION public.fs_last_user_activity( + userid text) + RETURNS TABLE("Id" text, "EpisodeId" text, "Name" text, "EpisodeName" text, "SeasonNumber" integer, "EpisodeNumber" integer, "PrimaryImageHash" text, "UserId" text, "UserName" text, archived boolean, "LastPlayed" interval) + LANGUAGE 'plpgsql' + COST 100 + VOLATILE PARALLEL UNSAFE + ROWS 1000 + + AS $BODY$ + BEGIN + RETURN QUERY + SELECT * + FROM ( + SELECT DISTINCT ON (i."Name", e."Name") + i."Id", + a."EpisodeId", + i."Name", + e."Name" AS "EpisodeName", + CASE WHEN a."SeasonId" IS NOT NULL THEN s."IndexNumber" ELSE NULL END AS "SeasonNumber", + CASE WHEN a."SeasonId" IS NOT NULL THEN e."IndexNumber" ELSE NULL END AS "EpisodeNumber", + i."PrimaryImageHash", + a."UserId", + a."UserName", + i.archived, + (NOW() - a."ActivityDateInserted") as "LastPlayed" + FROM jf_playback_activity a + JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" + LEFT JOIN jf_library_seasons s ON s."Id" = a."SeasonId" + LEFT JOIN jf_library_episodes e ON e."EpisodeId" = a."EpisodeId" + WHERE a."UserId" = userid + ORDER BY i."Name", e."Name", a."ActivityDateInserted" DESC + ) AS latest_distinct_rows + ORDER BY "LastPlayed"; + END; + + + $BODY$;`); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + try { + await knex.schema.raw(` + DROP FUNCTION IF EXISTS public.fs_last_user_activity(text); + + CREATE OR REPLACE FUNCTION public.fs_last_user_activity( + userid text) + RETURNS TABLE("Id" text, "EpisodeId" text, "Name" text, "EpisodeName" text, "SeasonNumber" integer, "EpisodeNumber" integer, "PrimaryImageHash" text, "UserId" text, "UserName" text, archived boolean, "LastPlayed" interval) + LANGUAGE 'plpgsql' + COST 100 + VOLATILE PARALLEL UNSAFE + ROWS 1000 + + AS $BODY$ + BEGIN + RETURN QUERY + SELECT * + FROM ( + SELECT DISTINCT ON (i."Name", e."Name") + i."Id", + a."EpisodeId", + i."Name", + e."Name" AS "EpisodeName", + CASE WHEN a."SeasonId" IS NOT NULL THEN s."IndexNumber" ELSE NULL END AS "SeasonNumber", + CASE WHEN a."SeasonId" IS NOT NULL THEN e."IndexNumber" ELSE NULL END AS "EpisodeNumber", + i."PrimaryImageHash", + a."UserId", + a."UserName", + i.archived, + (NOW() - a."ActivityDateInserted") as "LastPlayed" + FROM jf_playback_activity a + JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" + LEFT JOIN jf_library_seasons s ON s."Id" = a."SeasonId" + LEFT JOIN jf_library_episodes e ON e."EpisodeId" = a."EpisodeId" + WHERE a."UserId" = userid + ) AS latest_distinct_rows + ORDER BY "LastPlayed"; + END; + + + $BODY$;`); + } catch (error) { + console.error(error); + } +}; diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index b229c7a..1a9b020 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -250,5 +250,15 @@ "SAVE_JELLYFIN_DETAILS": "Save Jellyfin Details", "SETTINGS_SAVED": "Settings Saved", "SUCCESS": "Success", - "CREATE_USER": "Create User" + "CREATE_USER": "Create User", + "GEOLOCATION_INFO_FOR": "Geolocation Info for", + "CITY": "City", + "REGION": "Region", + "COUNTRY": "Country", + "ORGANIZATION": "Organization", + "ISP": "ISP", + "LATITUDE": "Latitude", + "LONGITUDE": "Longitude", + "TIMEZONE": "Timezone", + "POSTCODE": "Postcode" } diff --git a/src/pages/components/activity/activity-table.jsx b/src/pages/components/activity/activity-table.jsx index 1e85b2b..14f4d9a 100644 --- a/src/pages/components/activity/activity-table.jsx +++ b/src/pages/components/activity/activity-table.jsx @@ -1,48 +1,48 @@ -import React from 'react'; +import React from "react"; import { Link } from "react-router-dom"; -import { Button, ButtonGroup,Modal } from "react-bootstrap"; +import { Button, ButtonGroup, Modal } from "react-bootstrap"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Collapse from "@mui/material/Collapse"; +import TableSortLabel from "@mui/material/TableSortLabel"; +import IconButton from "@mui/material/IconButton"; +import Box from "@mui/material/Box"; +import { visuallyHidden } from "@mui/utils"; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import Collapse from '@mui/material/Collapse'; -import TableSortLabel from '@mui/material/TableSortLabel'; -import IconButton from '@mui/material/IconButton'; -import Box from '@mui/material/Box'; -import { visuallyHidden } from '@mui/utils'; +import AddCircleFillIcon from "remixicon-react/AddCircleFillIcon"; +import IndeterminateCircleFillIcon from "remixicon-react/IndeterminateCircleFillIcon"; +import StreamInfo from "./stream_info"; -import AddCircleFillIcon from 'remixicon-react/AddCircleFillIcon'; -import IndeterminateCircleFillIcon from 'remixicon-react/IndeterminateCircleFillIcon'; - -import StreamInfo from './stream_info'; - -import '../../css/activity/activity-table.css'; -import { Trans } from 'react-i18next'; -import i18next from 'i18next'; - +import "../../css/activity/activity-table.css"; +import { Trans } from "react-i18next"; +import i18next from "i18next"; +import IpInfoModal from "../ip-info"; function formatTotalWatchTime(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const remainingSeconds = seconds % 60; - let timeString = ''; + let timeString = ""; if (hours > 0) { - timeString += `${hours} ${hours === 1 ? 'hr' : 'hrs'} `; + timeString += `${hours} ${hours === 1 ? "hr" : "hrs"} `; } if (minutes > 0) { - timeString += `${minutes} ${minutes === 1 ? 'min' : 'mins'} `; + timeString += `${minutes} ${minutes === 1 ? "min" : "mins"} `; } if (remainingSeconds > 0) { - timeString += `${remainingSeconds} ${remainingSeconds === 1 ? i18next.t("UNITS.SECOND").toLowerCase() : i18next.t("UNITS.SECONDS").toLowerCase()}`; + timeString += `${remainingSeconds} ${ + remainingSeconds === 1 ? i18next.t("UNITS.SECOND").toLowerCase() : i18next.t("UNITS.SECONDS").toLowerCase() + }`; } return timeString.trim(); @@ -51,22 +51,42 @@ function formatTotalWatchTime(seconds) { function DataRow(data) { const { row } = data; const [open, setOpen] = React.useState(false); - const twelve_hr = JSON.parse(localStorage.getItem('12hr')); + const twelve_hr = JSON.parse(localStorage.getItem("12hr")); - - const [modalState,setModalState]= React.useState(false); - const [modalData,setModalData]= React.useState(); + const [modalState, setModalState] = React.useState(false); + const [modalData, setModalData] = React.useState(); + + //IP MODAL + + const ipv4Regex = new RegExp( + /\b(?!(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168))(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/ + ); + + const [ipModalVisible, setIPModalVisible] = React.useState(false); + const [ipAddressLookup, setIPAddressLookup] = React.useState(); + + const isRemoteSession = (ipAddress) => { + ipv4Regex.lastIndex = 0; + if (ipv4Regex.test(ipAddress ?? ipAddressLookup)) { + return true; + } + return false; + }; - const openModal = (data) => { setModalData(data); setModalState(!modalState); }; - - - + function showIPDataModal(ipAddress) { + ipv4Regex.lastIndex = 0; + setIPAddressLookup(ipAddress); + if (!isRemoteSession) { + return; + } + setIPModalVisible(true); + } const options = { day: "numeric", @@ -78,69 +98,142 @@ function DataRow(data) { hour12: twelve_hr, }; - return ( + setIPModalVisible(false)} ipAddress={ipAddressLookup} /> - setModalState(false)} > + setModalState(false)}> - : {!row.SeriesName ? row.NowPlayingItemName : row.SeriesName+' - '+ row.NowPlayingItemName} ({row.UserName}) + + :{" "} + {!row.SeriesName ? row.NowPlayingItemName : row.SeriesName + " - " + row.NowPlayingItemName} ({row.UserName}) + - + - - *': { borderBottom: 'unset' } }}> + *": { borderBottom: "unset" } }}> {if(row.TotalPlays>1){setOpen(!open);}}} - > - {!open ? 1 ?1 : 0} cursor={row.TotalPlays>1 ? "pointer":"default"}/> : } + onClick={() => { + if (row.TotalPlays > 1) { + setOpen(!open); + } + }} + > + {!open ? ( + 1 ? 1 : 0} cursor={row.TotalPlays > 1 ? "pointer" : "default"} /> + ) : ( + + )} - {row.UserName} - {row.RemoteEndPoint || '-'} - {!row.SeriesName ? row.NowPlayingItemName : row.SeriesName+' - '+ row.NowPlayingItemName} - openModal(row)}>{row.Client} - {Intl.DateTimeFormat('en-UK', options).format(new Date(row.ActivityDateInserted))} - {formatTotalWatchTime(row.PlaybackDuration) || '0 seconds'} + + + {row.UserName} + + + {isRemoteSession(row.RemoteEndPoint) && + import.meta.env.VITE_GEOLITE_ACCOUNT_ID && + import.meta.env.VITE_GEOLITE_LICENSE_KEY ? ( + + showIPDataModal(row.RemoteEndPoint)}> + {row.RemoteEndPoint} + + + ) : ( + {row.RemoteEndPoint || "-"} + )} + + + {!row.SeriesName ? row.NowPlayingItemName : row.SeriesName + " - " + row.NowPlayingItemName} + + + + openModal(row)}>{row.Client} + + {Intl.DateTimeFormat("en-UK", options).format(new Date(row.ActivityDateInserted))} + {formatTotalWatchTime(row.PlaybackDuration) || "0 seconds"} {row.TotalPlays} - - +
- - - - - - - + + + + + + + + + + + + + + + + + + + + + - {row.results.sort((a, b) => new Date(b.ActivityDateInserted) - new Date(a.ActivityDateInserted)).map((resultRow) => ( - - {resultRow.UserName} - {resultRow.RemoteEndPoint || '-'} - {!resultRow.SeriesName ? resultRow.NowPlayingItemName : resultRow.SeriesName+' - '+ resultRow.NowPlayingItemName} - openModal(resultRow)}>{resultRow.Client} - {Intl.DateTimeFormat('en-UK', options).format(new Date(resultRow.ActivityDateInserted))} - {formatTotalWatchTime(resultRow.PlaybackDuration) || '0 seconds'} + {row.results + .sort((a, b) => new Date(b.ActivityDateInserted) - new Date(a.ActivityDateInserted)) + .map((resultRow) => ( + + + + {resultRow.UserName} + + + {isRemoteSession(resultRow.RemoteEndPoint) && + import.meta.env.VITE_GEOLITE_ACCOUNT_ID && + import.meta.env.VITE_GEOLITE_LICENSE_KEY ? ( + + showIPDataModal(resultRow.RemoteEndPoint)}> + {resultRow.RemoteEndPoint} + + + ) : ( + {resultRow.RemoteEndPoint || "-"} + )} + + + {!resultRow.SeriesName + ? resultRow.NowPlayingItemName + : resultRow.SeriesName + " - " + resultRow.NowPlayingItemName} + + + + openModal(resultRow)}>{resultRow.Client} + + + {Intl.DateTimeFormat("en-UK", options).format(new Date(resultRow.ActivityDateInserted))} + + {formatTotalWatchTime(resultRow.PlaybackDuration) || "0 seconds"} 1 - - ))} + + ))}
@@ -152,78 +245,76 @@ function DataRow(data) { } function EnhancedTableHead(props) { - const { order, orderBy, onRequestSort } = - props; + const { order, orderBy, onRequestSort } = props; const createSortHandler = (property) => (event) => { onRequestSort(event, property); }; const headCells = [ { - id: 'UserName', + id: "UserName", numeric: false, disablePadding: false, label: i18next.t("USER"), }, { - id: 'RemoteEndPoint', + id: "RemoteEndPoint", numeric: false, disablePadding: false, label: i18next.t("ACTIVITY_TABLE.IP_ADDRESS"), }, { - id: 'NowPlayingItemName', + id: "NowPlayingItemName", numeric: false, disablePadding: false, label: i18next.t("TITLE"), }, { - id: 'Client', + id: "Client", numeric: false, disablePadding: false, label: i18next.t("ACTIVITY_TABLE.CLIENT"), }, { - id: 'ActivityDateInserted', + id: "ActivityDateInserted", numeric: false, disablePadding: false, label: i18next.t("DATE"), }, { - id: 'PlaybackDuration', + id: "PlaybackDuration", numeric: false, disablePadding: false, label: i18next.t("ACTIVITY_TABLE.TOTAL_PLAYBACK"), - }, + }, { - id: 'TotalPlays', + id: "TotalPlays", numeric: false, disablePadding: false, label: i18next.t("TOTAL_PLAYS"), }, ]; - return ( - + {headCells.map((headCell) => ( {headCell.label} {orderBy === headCell.id ? ( - {order === 'desc' ? 'sorted descending' : 'sorted ascending'} + {order === "desc" ? "sorted descending" : "sorted ascending"} ) : null} @@ -238,122 +329,120 @@ export default function ActivityTable(props) { const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); - const [order, setOrder] = React.useState('desc'); - const [orderBy, setOrderBy] = React.useState('ActivityDateInserted'); + const [order, setOrder] = React.useState("desc"); + const [orderBy, setOrderBy] = React.useState("ActivityDateInserted"); - - if(rowsPerPage!==props.itemCount) - { + if (rowsPerPage !== props.itemCount) { setRowsPerPage(props.itemCount); setPage(0); } - const handleNextPageClick = () => { setPage((prevPage) => Number(prevPage) + 1); - }; const handlePreviousPageClick = () => { setPage((prevPage) => Number(prevPage) - 1); - }; + function descendingComparator(a, b, orderBy) { + if (b[orderBy] < a[orderBy]) { + return -1; + } + if (b[orderBy] > a[orderBy]) { + return 1; + } + return 0; + } - function descendingComparator(a, b, orderBy) { - if (b[orderBy] < a[orderBy]) { - return -1; + // eslint-disable-next-line + function getComparator(order, orderBy) { + return order === "desc" ? (a, b) => descendingComparator(a, b, orderBy) : (a, b) => -descendingComparator(a, b, orderBy); + } + + function stableSort(array, comparator) { + const stabilizedThis = array.map((el, index) => [el, index]); + + stabilizedThis.sort((a, b) => { + const order = comparator(a[0], b[0]); + if (order !== 0) { + return order; } - if (b[orderBy] > a[orderBy]) { - return 1; - } - return 0; - } - - // eslint-disable-next-line - function getComparator(order, orderBy) { - return order === 'desc' - ? (a, b) => descendingComparator(a, b, orderBy) - : (a, b) => -descendingComparator(a, b, orderBy); - } - + return a[1] - b[1]; + }); - function stableSort(array, comparator) { - const stabilizedThis = array.map((el, index) => [el, index]); + return stabilizedThis.map((el) => el[0]); + } - stabilizedThis.sort((a, b) => { + const visibleRows = React.useMemo( + () => + stableSort(props.data, getComparator(order, orderBy)).slice( + page * Number(rowsPerPage), + page * Number(rowsPerPage) + Number(rowsPerPage) + ), + [order, orderBy, page, rowsPerPage, getComparator, props.data] + ); - const order = comparator(a[0], b[0]); - if (order !== 0) { - return order; - } - return a[1] - b[1]; - - }); - - return stabilizedThis.map((el) => el[0]); - } - - const visibleRows = React.useMemo( - () => - stableSort(props.data, getComparator(order, orderBy)).slice( - page * Number(rowsPerPage), - page * Number(rowsPerPage) + Number(rowsPerPage), - ), - [order, orderBy, page, rowsPerPage, getComparator, props.data], - ); - - const handleRequestSort = (event, property) => { - const isAsc = orderBy === property && order === 'asc'; - setOrder(isAsc ? 'desc' : 'asc'); - setOrderBy(property); - }; - - - + const handleRequestSort = (event, property) => { + const isAsc = orderBy === property && order === "asc"; + setOrder(isAsc ? "desc" : "asc"); + setOrderBy(property); + }; return ( <> - - - + +
+ {visibleRows.map((row) => ( - - ))} - {props.data.length===0 ? :''} - + + ))} + {props.data.length === 0 ? ( + + + + ) : ( + "" + )}
+ +
-
- - +
+ + - + -
{`${(page *rowsPerPage) + 1}-${Math.min(((page * rowsPerPage)+ 1 ) + (rowsPerPage - 1),props.data.length)} of ${props.data.length}`}
+
{`${page * rowsPerPage + 1}-${Math.min( + page * rowsPerPage + 1 + (rowsPerPage - 1), + props.data.length + )} of ${props.data.length}`}
- + - -
-
- + +
+
); -} \ No newline at end of file +} diff --git a/src/pages/components/ip-info.jsx b/src/pages/components/ip-info.jsx index 5d3d040..01e1b91 100644 --- a/src/pages/components/ip-info.jsx +++ b/src/pages/components/ip-info.jsx @@ -2,66 +2,119 @@ import React from "react"; import Loading from "./general/loading"; import { Button, Modal } from "react-bootstrap"; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import axios from "axios"; +import { Trans } from "react-i18next"; export default function IpInfoModal(props) { - let modalBody = ; - - if(props.geodata) { - modalBody = -
- - - - - - - - - - - City - {props.geodata.city.names['en']} - - - Country - {props.geodata.country.names['en']} - - - Postcode - {props.geodata.postal.code} - - - ISP - {props.geodata.traits.autonomous_system_organization} - - -
-
-
-
- } - - return ( -
- props.onHide()}> - - - Geolocation info for {props.ipAddress} - - - {modalBody} - - - - -
+ const token = localStorage.getItem("token"); + const [geodata, setGeodata] = React.useState(null); + const fetchData = async () => { + const result = await axios.post( + `/utils/geolocateIp`, + { + ipAddress: props.ipAddress, + }, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + } ); -} \ No newline at end of file + + setGeodata(result.data); + }; + + if (!geodata && props.show) { + fetchData(); + } + let modalBody = ; + + if (geodata) { + modalBody = ( + +
+ + + + + + + + + + {geodata.city?.names["en"] && ( + + + + + {geodata.city.names["en"]} + + )} + {geodata.country?.names["en"] && ( + + + + + {geodata.country.names["en"]} + + )} + {geodata.postal?.code && ( + + + + + {geodata.postal.code} + + )} + {geodata.traits?.autonomous_system_organization && ( + + + + + {geodata.traits.autonomous_system_organization} + + )} + +
+ {geodata.location?.latitude && geodata.location?.longitude && ( + + )} +
+
+
+ ); + } + + return ( +
+ props.onHide()}> + + + {props.ipAddress} + + + {modalBody} + + + + +
+ ); +} diff --git a/src/pages/components/sessions/session-card.jsx b/src/pages/components/sessions/session-card.jsx index 0354d5d..288f628 100644 --- a/src/pages/components/sessions/session-card.jsx +++ b/src/pages/components/sessions/session-card.jsx @@ -74,56 +74,35 @@ function SessionCard(props) { - const token = localStorage.getItem('token'); - const ipv4Regex = new RegExp(/\b(?!(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168))(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/); - const [modalVisible, setModalVisible] = useState(false); - const [modalData, setModalData] = useState(); - const [isRemoteSession, setIsRemoteSession] = useState(); + const [ipModalVisible, setIPModalVisible] = React.useState(false); + const [ipAddressLookup, setIPAddressLookup] = React.useState(); - useEffect(() => { + const isRemoteSession = (ipAddress) => { ipv4Regex.lastIndex = 0; - if(ipv4Regex.test(props.data.session.RemoteEndPoint)) { - setIsRemoteSession(true) - } - }, []); + if (ipv4Regex.test(ipAddress ?? ipAddressLookup)) { + return true; + } + return false; + }; - function showModal() { - if(!isRemoteSession) { - return + + function showIPDataModal(ipAddress) { + ipv4Regex.lastIndex = 0; + setIPAddressLookup(ipAddress); + if (!isRemoteSession) { + return; } - const fetchData = async () => { - const result = await axios.post(`/utils/geolocateIp`, { - ipAddress: props.data.session.RemoteEndPoint - }, { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - } - }); - setModalData(result.data); - }; - - if(!modalData) { - fetchData(); - } - - setModalVisible(true); + setIPModalVisible(true); } - function hideModal() { - setModalVisible(false); - } + return ( - + setIPModalVisible(false)} ipAddress={ipAddressLookup} />
@@ -162,9 +141,8 @@ function SessionCard(props) { - {isRemoteSession && (import.meta.env.VITE_GEOLITE_ACCOUNT_ID && import.meta.env.VITE_GEOLITE_LICENSE_KEY) ? + {isRemoteSession(props.data.session.RemoteEndPoint) && (import.meta.env.VITE_GEOLITE_ACCOUNT_ID && import.meta.env.VITE_GEOLITE_LICENSE_KEY) ? - IP Address: {props.data.session.RemoteEndPoint} :