mirror of
https://github.com/BreizhHardware/Jellystat.git
synced 2026-01-18 16:27:20 +01:00
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
This commit is contained in:
92
backend/migrations/063_fs_last_user_activity_fix_1.js
Normal file
92
backend/migrations/063_fs_last_user_activity_fix_1.js
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<React.Fragment>
|
||||
<IpInfoModal show={ipModalVisible} onHide={() => setIPModalVisible(false)} ipAddress={ipAddressLookup} />
|
||||
|
||||
<Modal show={modalState} onHide={()=>setModalState(false)} >
|
||||
<Modal show={modalState} onHide={() => setModalState(false)}>
|
||||
<Modal.Header>
|
||||
<Modal.Title><Trans i18nKey="ACTIVITY_TABLE.MODAL.HEADER"/>: {!row.SeriesName ? row.NowPlayingItemName : row.SeriesName+' - '+ row.NowPlayingItemName} ({row.UserName})</Modal.Title>
|
||||
<Modal.Title>
|
||||
<Trans i18nKey="ACTIVITY_TABLE.MODAL.HEADER" />:{" "}
|
||||
{!row.SeriesName ? row.NowPlayingItemName : row.SeriesName + " - " + row.NowPlayingItemName} ({row.UserName})
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<StreamInfo data={modalData}/>
|
||||
<StreamInfo data={modalData} />
|
||||
<Modal.Footer>
|
||||
<Button variant="outline-primary" onClick={()=>setModalState(false)}>
|
||||
<Trans i18nKey="CLOSE"/>
|
||||
<Button variant="outline-primary" onClick={() => setModalState(false)}>
|
||||
<Trans i18nKey="CLOSE" />
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>
|
||||
<TableRow sx={{ "& > *": { borderBottom: "unset" } }}>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
aria-label="expand row"
|
||||
size="small"
|
||||
onClick={() => {if(row.TotalPlays>1){setOpen(!open);}}}
|
||||
>
|
||||
{!open ? <AddCircleFillIcon opacity={row.TotalPlays>1 ?1 : 0} cursor={row.TotalPlays>1 ? "pointer":"default"}/> : <IndeterminateCircleFillIcon />}
|
||||
onClick={() => {
|
||||
if (row.TotalPlays > 1) {
|
||||
setOpen(!open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!open ? (
|
||||
<AddCircleFillIcon opacity={row.TotalPlays > 1 ? 1 : 0} cursor={row.TotalPlays > 1 ? "pointer" : "default"} />
|
||||
) : (
|
||||
<IndeterminateCircleFillIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell><Link to={`/users/${row.UserId}`} className='text-decoration-none'>{row.UserName}</Link></TableCell>
|
||||
<TableCell>{row.RemoteEndPoint || '-'}</TableCell>
|
||||
<TableCell><Link to={`/libraries/item/${row.EpisodeId || row.NowPlayingItemId}`} className='text-decoration-none'>{!row.SeriesName ? row.NowPlayingItemName : row.SeriesName+' - '+ row.NowPlayingItemName}</Link></TableCell>
|
||||
<TableCell className='activity-client' ><span onClick={()=>openModal(row)}>{row.Client}</span></TableCell>
|
||||
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(row.ActivityDateInserted))}</TableCell>
|
||||
<TableCell>{formatTotalWatchTime(row.PlaybackDuration) || '0 seconds'}</TableCell>
|
||||
<TableCell>
|
||||
<Link to={`/users/${row.UserId}`} className="text-decoration-none">
|
||||
{row.UserName}
|
||||
</Link>
|
||||
</TableCell>
|
||||
{isRemoteSession(row.RemoteEndPoint) &&
|
||||
import.meta.env.VITE_GEOLITE_ACCOUNT_ID &&
|
||||
import.meta.env.VITE_GEOLITE_LICENSE_KEY ? (
|
||||
<TableCell>
|
||||
<Link className="text-decoration-none" onClick={() => showIPDataModal(row.RemoteEndPoint)}>
|
||||
{row.RemoteEndPoint}
|
||||
</Link>
|
||||
</TableCell>
|
||||
) : (
|
||||
<TableCell>{row.RemoteEndPoint || "-"}</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Link to={`/libraries/item/${row.EpisodeId || row.NowPlayingItemId}`} className="text-decoration-none">
|
||||
{!row.SeriesName ? row.NowPlayingItemName : row.SeriesName + " - " + row.NowPlayingItemName}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="activity-client">
|
||||
<span onClick={() => openModal(row)}>{row.Client}</span>
|
||||
</TableCell>
|
||||
<TableCell>{Intl.DateTimeFormat("en-UK", options).format(new Date(row.ActivityDateInserted))}</TableCell>
|
||||
<TableCell>{formatTotalWatchTime(row.PlaybackDuration) || "0 seconds"}</TableCell>
|
||||
<TableCell>{row.TotalPlays}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={8}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ margin: 1 }}>
|
||||
|
||||
<Table aria-label="sub-activity" className='rounded-2'>
|
||||
<Table aria-label="sub-activity" className="rounded-2">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell><Trans i18nKey="USER"/></TableCell>
|
||||
<TableCell><Trans i18nKey="ACTIVITY_TABLE.IP_ADDRESS"/></TableCell>
|
||||
<TableCell><Trans i18nKey="TITLE"/></TableCell>
|
||||
<TableCell><Trans i18nKey="ACTIVITY_TABLE.CLIENT"/></TableCell>
|
||||
<TableCell><Trans i18nKey="DATE"/></TableCell>
|
||||
<TableCell><Trans i18nKey="ACTIVITY_TABLE.PLAYBACK_DURATION"/></TableCell>
|
||||
<TableCell><Trans i18nKey="UNITS.PLAYS"/></TableCell>
|
||||
<TableCell>
|
||||
<Trans i18nKey="USER" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Trans i18nKey="ACTIVITY_TABLE.IP_ADDRESS" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Trans i18nKey="TITLE" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Trans i18nKey="ACTIVITY_TABLE.CLIENT" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Trans i18nKey="DATE" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Trans i18nKey="ACTIVITY_TABLE.PLAYBACK_DURATION" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Trans i18nKey="UNITS.PLAYS" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{row.results.sort((a, b) => new Date(b.ActivityDateInserted) - new Date(a.ActivityDateInserted)).map((resultRow) => (
|
||||
<TableRow key={resultRow.Id}>
|
||||
<TableCell><Link to={`/users/${resultRow.UserId}`} className='text-decoration-none'>{resultRow.UserName}</Link></TableCell>
|
||||
<TableCell>{resultRow.RemoteEndPoint || '-'}</TableCell>
|
||||
<TableCell><Link to={`/libraries/item/${resultRow.EpisodeId || resultRow.NowPlayingItemId}`} className='text-decoration-none'>{!resultRow.SeriesName ? resultRow.NowPlayingItemName : resultRow.SeriesName+' - '+ resultRow.NowPlayingItemName}</Link></TableCell>
|
||||
<TableCell className='activity-client' ><span onClick={()=>openModal(resultRow)}>{resultRow.Client}</span></TableCell>
|
||||
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(resultRow.ActivityDateInserted))}</TableCell>
|
||||
<TableCell>{formatTotalWatchTime(resultRow.PlaybackDuration) || '0 seconds'}</TableCell>
|
||||
{row.results
|
||||
.sort((a, b) => new Date(b.ActivityDateInserted) - new Date(a.ActivityDateInserted))
|
||||
.map((resultRow) => (
|
||||
<TableRow key={resultRow.Id}>
|
||||
<TableCell>
|
||||
<Link to={`/users/${resultRow.UserId}`} className="text-decoration-none">
|
||||
{resultRow.UserName}
|
||||
</Link>
|
||||
</TableCell>
|
||||
{isRemoteSession(resultRow.RemoteEndPoint) &&
|
||||
import.meta.env.VITE_GEOLITE_ACCOUNT_ID &&
|
||||
import.meta.env.VITE_GEOLITE_LICENSE_KEY ? (
|
||||
<TableCell>
|
||||
<Link className="text-decoration-none" onClick={() => showIPDataModal(resultRow.RemoteEndPoint)}>
|
||||
{resultRow.RemoteEndPoint}
|
||||
</Link>
|
||||
</TableCell>
|
||||
) : (
|
||||
<TableCell>{resultRow.RemoteEndPoint || "-"}</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/libraries/item/${resultRow.EpisodeId || resultRow.NowPlayingItemId}`}
|
||||
className="text-decoration-none"
|
||||
>
|
||||
{!resultRow.SeriesName
|
||||
? resultRow.NowPlayingItemName
|
||||
: resultRow.SeriesName + " - " + resultRow.NowPlayingItemName}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="activity-client">
|
||||
<span onClick={() => openModal(resultRow)}>{resultRow.Client}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{Intl.DateTimeFormat("en-UK", options).format(new Date(resultRow.ActivityDateInserted))}
|
||||
</TableCell>
|
||||
<TableCell>{formatTotalWatchTime(resultRow.PlaybackDuration) || "0 seconds"}</TableCell>
|
||||
<TableCell>1</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
@@ -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 (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell/>
|
||||
<TableCell />
|
||||
{headCells.map((headCell) => (
|
||||
<TableCell
|
||||
key={headCell.id}
|
||||
align={headCell.numeric ? 'right' : 'left'}
|
||||
padding={headCell.disablePadding ? 'none' : 'normal'}
|
||||
align={headCell.numeric ? "right" : "left"}
|
||||
padding={headCell.disablePadding ? "none" : "normal"}
|
||||
sortDirection={orderBy === headCell.id ? order : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={orderBy === headCell.id}
|
||||
direction={orderBy === headCell.id ? order : 'asc'}
|
||||
direction={orderBy === headCell.id ? order : "asc"}
|
||||
onClick={createSortHandler(headCell.id)}
|
||||
>
|
||||
{headCell.label}
|
||||
{orderBy === headCell.id ? (
|
||||
<Box component="span" sx={visuallyHidden}>
|
||||
{order === 'desc' ? 'sorted descending' : 'sorted ascending'}
|
||||
{order === "desc" ? "sorted descending" : "sorted ascending"}
|
||||
</Box>
|
||||
) : null}
|
||||
</TableSortLabel>
|
||||
@@ -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 (
|
||||
<>
|
||||
<TableContainer className='rounded-2'>
|
||||
<Table aria-label="collapsible table" >
|
||||
<EnhancedTableHead
|
||||
order={order}
|
||||
orderBy={orderBy}
|
||||
onRequestSort={handleRequestSort}
|
||||
rowCount={rowsPerPage}
|
||||
/>
|
||||
<TableContainer className="rounded-2">
|
||||
<Table aria-label="collapsible table">
|
||||
<EnhancedTableHead order={order} orderBy={orderBy} onRequestSort={handleRequestSort} rowCount={rowsPerPage} />
|
||||
<TableBody>
|
||||
{visibleRows.map((row) => (
|
||||
<DataRow key={row.Id+row.NowPlayingItemId+row.EpisodeId} row={row} />
|
||||
))}
|
||||
{props.data.length===0 ? <tr><td colSpan="8" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}} className='py-2'><Trans i18nKey="ERROR_MESSAGES.NO_ACTIVITY"/></td></tr> :''}
|
||||
|
||||
<DataRow key={row.Id + row.NowPlayingItemId + row.EpisodeId} row={row} />
|
||||
))}
|
||||
{props.data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="8" style={{ textAlign: "center", fontStyle: "italic", color: "grey" }} className="py-2">
|
||||
<Trans i18nKey="ERROR_MESSAGES.NO_ACTIVITY" />
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<div className='d-flex justify-content-end my-2'>
|
||||
<ButtonGroup className="pagination-buttons">
|
||||
<Button className="page-btn" onClick={()=>setPage(0)} disabled={page === 0}>
|
||||
<Trans i18nKey="TABLE_NAV_BUTTONS.FIRST"/>
|
||||
</Button>
|
||||
<div className="d-flex justify-content-end my-2">
|
||||
<ButtonGroup className="pagination-buttons">
|
||||
<Button className="page-btn" onClick={() => setPage(0)} disabled={page === 0}>
|
||||
<Trans i18nKey="TABLE_NAV_BUTTONS.FIRST" />
|
||||
</Button>
|
||||
|
||||
<Button className="page-btn" onClick={handlePreviousPageClick} disabled={page === 0}>
|
||||
<Trans i18nKey="TABLE_NAV_BUTTONS.PREVIOUS"/>
|
||||
</Button>
|
||||
<Button className="page-btn" onClick={handlePreviousPageClick} disabled={page === 0}>
|
||||
<Trans i18nKey="TABLE_NAV_BUTTONS.PREVIOUS" />
|
||||
</Button>
|
||||
|
||||
<div className="page-number d-flex align-items-center justify-content-center">{`${(page *rowsPerPage) + 1}-${Math.min(((page * rowsPerPage)+ 1 ) + (rowsPerPage - 1),props.data.length)} of ${props.data.length}`}</div>
|
||||
<div className="page-number d-flex align-items-center justify-content-center">{`${page * rowsPerPage + 1}-${Math.min(
|
||||
page * rowsPerPage + 1 + (rowsPerPage - 1),
|
||||
props.data.length
|
||||
)} of ${props.data.length}`}</div>
|
||||
|
||||
<Button className="page-btn" onClick={handleNextPageClick} disabled={page >= Math.ceil(props.data.length / rowsPerPage) - 1}>
|
||||
<Trans i18nKey="TABLE_NAV_BUTTONS.NEXT"/>
|
||||
</Button>
|
||||
<Button
|
||||
className="page-btn"
|
||||
onClick={handleNextPageClick}
|
||||
disabled={page >= Math.ceil(props.data.length / rowsPerPage) - 1}
|
||||
>
|
||||
<Trans i18nKey="TABLE_NAV_BUTTONS.NEXT" />
|
||||
</Button>
|
||||
|
||||
<Button className="page-btn" onClick={()=>setPage(Math.ceil(props.data.length / rowsPerPage) - 1)} disabled={page >= Math.ceil(props.data.length / rowsPerPage) - 1}>
|
||||
<Trans i18nKey="TABLE_NAV_BUTTONS.LAST"/>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="page-btn"
|
||||
onClick={() => setPage(Math.ceil(props.data.length / rowsPerPage) - 1)}
|
||||
disabled={page >= Math.ceil(props.data.length / rowsPerPage) - 1}
|
||||
>
|
||||
<Trans i18nKey="TABLE_NAV_BUTTONS.LAST" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = <Loading/>;
|
||||
|
||||
if(props.geodata) {
|
||||
modalBody = <Modal.Body>
|
||||
<div className="StreamInfo">
|
||||
<TableContainer className="overflow-hidden">
|
||||
<Table aria-label="collapsible table" >
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell/>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="py-0 pb-1">City</TableCell>
|
||||
<TableCell>{props.geodata.city.names['en']}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="py-0 pb-1">Country</TableCell>
|
||||
<TableCell>{props.geodata.country.names['en']}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="py-0 pb-1">Postcode</TableCell>
|
||||
<TableCell>{props.geodata.postal.code}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="py-0 pb-1">ISP</TableCell>
|
||||
<TableCell>{props.geodata.traits.autonomous_system_organization}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Modal show={props.show} onHide={() => props.onHide()}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
Geolocation info for {props.ipAddress}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
{modalBody}
|
||||
<Modal.Footer>
|
||||
<Button variant="outline-primary" onClick={()=>props.onHide()}>
|
||||
Close
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</div>
|
||||
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",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setGeodata(result.data);
|
||||
};
|
||||
|
||||
if (!geodata && props.show) {
|
||||
fetchData();
|
||||
}
|
||||
let modalBody = <Loading />;
|
||||
|
||||
if (geodata) {
|
||||
modalBody = (
|
||||
<Modal.Body>
|
||||
<div className="StreamInfo">
|
||||
<TableContainer className="overflow-hidden">
|
||||
<Table aria-label="collapsible table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell />
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{geodata.city?.names["en"] && (
|
||||
<TableRow>
|
||||
<TableCell className="py-0 pb-1">
|
||||
<Trans i18nKey={"CITY"} />
|
||||
</TableCell>
|
||||
<TableCell>{geodata.city.names["en"]}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{geodata.country?.names["en"] && (
|
||||
<TableRow>
|
||||
<TableCell className="py-0 pb-1">
|
||||
<Trans i18nKey={"COUNTRY"} />
|
||||
</TableCell>
|
||||
<TableCell>{geodata.country.names["en"]}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{geodata.postal?.code && (
|
||||
<TableRow>
|
||||
<TableCell className="py-0 pb-1">
|
||||
<Trans i18nKey={"POSTCODE"} />
|
||||
</TableCell>
|
||||
<TableCell>{geodata.postal.code}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{geodata.traits?.autonomous_system_organization && (
|
||||
<TableRow>
|
||||
<TableCell className="py-0 pb-1">
|
||||
<Trans i18nKey={"ISP"} />
|
||||
</TableCell>
|
||||
<TableCell>{geodata.traits.autonomous_system_organization}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{geodata.location?.latitude && geodata.location?.longitude && (
|
||||
<iframe
|
||||
width="100%"
|
||||
height="450"
|
||||
style={{ border: 0 }}
|
||||
loading="async"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
src={`https://www.google.com/maps/embed/v1/place?key=AIzaSyAxp7Zvxi5FnTJJHwCnUR-OcZ-E1H2-gf4&q=${geodata.location?.latitude},${geodata.location?.longitude}&zoom=14`}
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Modal show={props.show} onHide={() => props.onHide()}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
<Trans i18nKey={"GEOLOCATION_INFO_FOR"} /> {props.ipAddress}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
{modalBody}
|
||||
<Modal.Footer>
|
||||
<Button variant="outline-primary" onClick={() => props.onHide()}>
|
||||
<Trans i18nKey={"CLOSE"} />
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Card className="stat-card" style={cardStyle}>
|
||||
<IpInfoModal
|
||||
show={modalVisible}
|
||||
onHide={hideModal}
|
||||
ipAddress={props.data.session.RemoteEndPoint}
|
||||
geodata={modalData}/>
|
||||
<IpInfoModal show={ipModalVisible} onHide={() => setIPModalVisible(false)} ipAddress={ipAddressLookup} />
|
||||
<div style={cardBgStyle} className="rounded-top">
|
||||
<Row className="h-100">
|
||||
<Col className="d-none d-lg-block stat-card-banner">
|
||||
@@ -162,9 +141,8 @@ function SessionCard(props) {
|
||||
<Row>
|
||||
<Col className="col-auto ellipse">
|
||||
|
||||
{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) ?
|
||||
<Card.Text>
|
||||
IP Address: <Link onClick={showModal}>{props.data.session.RemoteEndPoint}</Link>
|
||||
</Card.Text>
|
||||
:
|
||||
<span>
|
||||
|
||||
Reference in New Issue
Block a user