Automated Tasks + Logging +misc

New:
Added automated tasks for Sync and Backups - Backups run every  24hrs, Sync runs every 10 minutes
Added switcher to switch between 12hr/24hr time formats
Added sorting to Activity Tables
Added Version Checking and indicators
Added logging on Tasks + Log Page
Added Recently Added view to Home page
Added background images to item banners
Added Proxy for Device Images in session card
Changed Navbar to be a side bar

Fixes:
Fixed Jellyfin API returning Empty folder as library item
CSS File to add breakpoints to bootstrap-width

Other:
Various CSS changes
Temporarily removed Websockets due to Proxy Errors
Changed Activity View Playback conversion function to more accurately display Playback Duration
Backend changes to sum Playback Durations to show more accurate information in thee collapsed summarized view
This commit is contained in:
Thegan Govender
2023-05-25 07:21:24 +02:00
parent f37f763f50
commit 3a04661915
72 changed files with 1880 additions and 431 deletions

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@
# testing
/coverage
/backend/backup-data
.vscode
# production
/build

View File

@@ -1,9 +1,10 @@
// api.js
const express = require("express");
const axios = require("axios");
const ActivityMonitor=require('./watchdog/ActivityMonitor');
const ActivityMonitor=require('./tasks/ActivityMonitor');
const db = require("./db");
const https = require('https');
const { checkForUpdates } = require('./version-control');
const agent = new https.Agent({
rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true'
@@ -61,6 +62,19 @@ router.post("/setconfig", async (req, res) => {
console.log(`ENDPOINT CALLED: /setconfig: `);
});
router.get("/CheckForUpdates", async (req, res) => {
try{
let result=await checkForUpdates();
res.send(result);
}catch(error)
{
console.log(error);
}
});
router.get("/getLibraries", async (req, res) => {
try{
@@ -219,6 +233,14 @@ router.get("/getHistory", async (req, res) => {
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
}
});
// Update GroupedResults with playbackDurationSum
Object.values(groupedResults).forEach(row => {
if (row.results && row.results.length > 0) {
row.PlaybackDuration = row.results.reduce((acc, item) => acc + parseInt(item.PlaybackDuration), 0);
}
});
res.send(Object.values(groupedResults));

View File

@@ -3,9 +3,11 @@ const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const moment = require('moment');
const { randomUUID } = require('crypto');
const multer = require('multer');
const wss = require("./WebsocketHandler");
// const wss = require("./WebsocketHandler");
const Logging =require('./logging');
const router = Router();
@@ -21,7 +23,8 @@ const tables = ['jf_libraries', 'jf_library_items', 'jf_library_seasons','jf_lib
// Backup function
async function backup() {
async function backup(logData,result) {
logData.push({ color: "lawngreen", Message: "Starting Backup" });
const pool = new Pool({
user: postgresUser,
password: postgresPassword,
@@ -39,20 +42,19 @@ async function backup() {
const backupPath = `./backup-data/backup_${now.format('yyyy-MM-DD HH-mm-ss')}.json`;
const stream = fs.createWriteStream(backupPath, { flags: 'a' });
stream.on('error', (error) => {
console.error(error);
wss.sendMessageToClients({ color: "red", Message: "Backup Failed: "+error });
logData.push({ color: "red", Message: "Backup Failed: "+error });
result='Failed';
throw new Error(error);
});
const backup_data=[];
wss.clearMessages();
wss.sendMessageToClients({ color: "yellow", Message: "Begin Backup "+backupPath });
logData.push({ color: "yellow", Message: "Begin Backup "+backupPath });
for (let table of tables) {
const query = `SELECT * FROM ${table}`;
const { rows } = await pool.query(query);
console.log(`Reading ${rows.length} rows for table ${table}`);
wss.sendMessageToClients({color: "dodgerblue",Message: `Saving ${rows.length} rows for table ${table}`});
logData.push({color: "dodgerblue",Message: `Saving ${rows.length} rows for table ${table}`});
backup_data.push({[table]:rows});
@@ -61,12 +63,13 @@ async function backup() {
await stream.write(JSON.stringify(backup_data));
stream.end();
wss.sendMessageToClients({ color: "lawngreen", Message: "Backup Complete" });
logData.push({ color: "lawngreen", Message: "Backup Complete" });
}catch(error)
{
console.log(error);
wss.sendMessageToClients({ color: "red", Message: "Backup Failed: "+error });
logData.push({ color: "red", Message: "Backup Failed: "+error });
result='Failed';
}
@@ -161,7 +164,28 @@ async function restore(file) {
// Route handler for backup endpoint
router.get('/backup', async (req, res) => {
try {
await backup();
let startTime = moment();
let logData=[];
let result='Success';
await backup(logData,result);
let endTime = moment();
let diffInSeconds = endTime.diff(startTime, 'seconds');
const uuid = randomUUID();
const log=
{
"Id":uuid,
"Name":"Backup",
"Type":"Task",
"ExecutionType":"Manual",
"Duration":diffInSeconds,
"TimeRun":startTime,
"Log":JSON.stringify(logData),
"Result": result
};
Logging.insertLog(log);
res.send('Backup completed successfully');
} catch (error) {
console.error(error);
@@ -272,4 +296,8 @@ router.get('/restore/:filename', async (req, res) => {
module.exports = router;
module.exports =
{
router,
backup
};

View File

@@ -81,7 +81,7 @@ async function insertBulk(table_name, data,columns) {
await client.query("COMMIT");
message=(data.length + " Rows Inserted.");
message=((data.length||1) + " Rows Inserted.");
} catch (error) {
await client.query('ROLLBACK');

37
backend/logging.js Normal file
View File

@@ -0,0 +1,37 @@
const db = require("./db");
const {jf_logging_columns,jf_logging_mapping,} = require("./models/jf_logging");
const express = require("express");
const router = express.Router();
router.get("/getLogs", async (req, res) => {
try {
const { rows } = await db.query(`SELECT * FROM jf_logging order by "TimeRun" desc LIMIT 50 `);
res.send(rows);
} catch (error) {
res.send(error);
}
});
async function insertLog(logItem)
{
try {
await db.insertBulk("jf_logging",logItem,jf_logging_columns);
// console.log(result);
} catch (error) {
console.log(error);
return [];
}
}
module.exports =
{router,insertLog};

View File

@@ -0,0 +1,29 @@
exports.up = async function(knex) {
try {
const hasTable = await knex.schema.hasTable('jf_logging');
if (!hasTable) {
await knex.schema.createTable('jf_logging', function(table) {
table.text('Id').primary();
table.text('Name').notNullable();
table.text('Type').notNullable();
table.text('ExecutionType');
table.text('Duration').notNullable();
table.timestamp('TimeRun').defaultTo(knex.fn.now());
table.json('Log');
table.text('Result');
});
await knex.raw(`ALTER TABLE jf_logging OWNER TO ${process.env.POSTGRES_USER};`);
}
} catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.dropTableIfExists('jf_logging');
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,26 @@
const jf_logging_columns = [
"Id",
"Name",
"Type",
"ExecutionType",
"Duration",
"TimeRun",
"Log",
"Result"
];
const jf_logging_mapping = (item) => ({
Id: item.Id,
Name: item.Name,
Type: item.Type,
ExecutionType: item.ExecutionType,
Duration: item.Duration,
TimeRun: item.TimeRun,
Log: item.Log,
Result: item.Result,
});
module.exports = {
jf_logging_columns,
jf_logging_mapping,
};

View File

@@ -14,8 +14,43 @@ const axios_instance = axios.create({
const router = express.Router();
router.get('/web/assets/img/devices/', async(req, res) => {
const { devicename } = req.query; // Get the image URL from the query string
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null || devicename===undefined) {
res.send({ error: "Config Details Not Found" });
return;
}
let url=`${config[0].JF_HOST}/web/assets/img/devices/${devicename}.svg`;
axios_instance.get(url, {
responseType: 'arraybuffer'
})
.then((response) => {
res.set('Content-Type', 'image/svg+xml');
res.status(200);
if (response.headers['content-type'].startsWith('image/')) {
res.send(response.data);
} else {
res.send(response.data.toString());
}
return; // Add this line
})
.catch((error) => {
console.error(error);
res.status(500).send('Error fetching image');
});
});
router.get('/Items/Images/Backdrop/', async(req, res) => {
const { id,fillWidth,quality } = req.query; // Get the image URL from the query string
const { id,fillWidth,quality,blur } = req.query; // Get the image URL from the query string
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
@@ -24,7 +59,7 @@ router.get('/Items/Images/Backdrop/', async(req, res) => {
}
let url=`${config[0].JF_HOST}/Items/${id}/Images/Backdrop?fillWidth=${fillWidth || 100}&quality=${quality || 100}`;
let url=`${config[0].JF_HOST}/Items/${id}/Images/Backdrop?fillWidth=${fillWidth || 800}&quality=${quality || 100}&blur=${blur || 0}`;
axios_instance.get(url, {
@@ -56,7 +91,7 @@ router.get('/Items/Images/Backdrop/', async(req, res) => {
}
let url=`${config[0].JF_HOST}/Items/${id}/Images/Primary?fillWidth=${fillWidth || 100}&quality=${quality || 100}`;
let url=`${config[0].JF_HOST}/Items/${id}/Images/Primary?fillWidth=${fillWidth || 400}&quality=${quality || 100}`;
axios_instance.get(url, {
responseType: 'arraybuffer'

View File

@@ -8,12 +8,13 @@ const knexConfig = require('./migrations');
const authRouter= require('./auth');
const apiRouter = require('./api');
const proxyRouter = require('./proxy');
const syncRouter = require('./sync');
const {router: syncRouter} = require('./sync');
const statsRouter = require('./stats');
const backupRouter = require('./backup');
const ActivityMonitor = require('./watchdog/ActivityMonitor');
const { checkForUpdates } = require('./version-control');
const {router: backupRouter} = require('./backup');
const ActivityMonitor = require('./tasks/ActivityMonitor');
const SyncTask = require('./tasks/SyncTask');
const BackupTask = require('./tasks/BackupTask');
const {router: logRouter} = require('./logging');
@@ -57,6 +58,7 @@ app.use('/proxy', proxyRouter); // mount the API router at /api, with JWT middle
app.use('/sync', verifyToken, syncRouter); // mount the API router at /sync, with JWT middleware
app.use('/stats', verifyToken, statsRouter); // mount the API router at /stats, with JWT middleware
app.use('/data', verifyToken, backupRouter); // mount the API router at /stats, with JWT middleware
app.use('/logs', verifyToken, logRouter); // mount the API router at /stats, with JWT middleware
try{
createdb.createDatabase().then((result) => {
@@ -67,8 +69,10 @@ try{
db.migrate.latest().then(() => {
app.listen(PORT, async () => {
console.log(`Server listening on http://${LISTEN_IP}:${PORT}`);
checkForUpdates();
ActivityMonitor.ActivityMonitor(1000);
SyncTask.SyncTask(60000*10);
BackupTask.BackupTask(60000*60*24);
});
});
});

View File

@@ -308,7 +308,11 @@ router.get("/getRecentlyAdded", async (req, res) => {
);
let url=`${config[0].JF_HOST}/users/${adminUser[0].Id}/Items/latest?parentId=${libraryid}`;
let url=`${config[0].JF_HOST}/users/${adminUser[0].Id}/Items/latest`;
if(libraryid)
{
url+=`?parentId=${libraryid}`;
}
const response_data = await axios.get(url, {
headers: {

View File

@@ -4,6 +4,8 @@ const db = require("./db");
const axios = require("axios");
const https = require('https');
const logging=require("./logging");
const agent = new https.Agent({
rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true'
});
@@ -17,6 +19,9 @@ const axios_instance = axios.create({
const wss = require("./WebsocketHandler");
const socket=wss;
const moment = require('moment');
const { randomUUID } = require('crypto');
const router = express.Router();
@@ -39,7 +44,6 @@ class sync {
async getUsers() {
try {
const url = `${this.hostUrl}/Users`;
console.log("getAdminUser: ", url);
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
@@ -55,7 +59,6 @@ class sync {
async getAdminUser() {
try {
const url = `${this.hostUrl}/Users`;
console.log("getAdminUser: ", url);
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
@@ -90,7 +93,7 @@ class sync {
["tvshows", "movies","music"].includes(type.CollectionType)
);
} else {
return results;
return results.filter((item)=> item.ImageTags.Primary);
}
} catch (error) {
console.log(error);
@@ -141,12 +144,13 @@ class sync {
}
////////////////////////////////////////API Methods
async function syncUserData()
async function syncUserData(loggedData,result)
{
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
socket.sendMessageToClients({ Message: "Error: Config details not found!" });
loggedData.push({ Message: "Error: Config details not found!" });
result='Failed';
return;
}
@@ -172,12 +176,13 @@ async function syncUserData()
if (dataToInsert.length !== 0) {
let result = await db.insertBulk("jf_users",dataToInsert,jf_users_columns);
if (result.Result === "SUCCESS") {
socket.sendMessageToClients(dataToInsert.length + " Rows Inserted.");
loggedData.push(dataToInsert.length + " Rows Inserted.");
} else {
socket.sendMessageToClients({
loggedData.push({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
result='Failed';
}
}
@@ -185,21 +190,24 @@ async function syncUserData()
if (toDeleteIds.length > 0) {
let result = await db.deleteBulk("jf_users",toDeleteIds);
if (result.Result === "SUCCESS") {
socket.sendMessageToClients(toDeleteIds.length + " Rows Removed.");
loggedData.push(toDeleteIds.length + " Rows Removed.");
} else {
socket.sendMessageToClients({color: "red",Message: result.message,});
loggedData.push({color: "red",Message: result.message,});
result='Failed';
}
}
}
async function syncLibraryFolders()
async function syncLibraryFolders(loggedData,result)
{
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
socket.sendMessageToClients({ Message: "Error: Config details not found!" });
loggedData.push({ Message: "Error: Config details not found!" });
result='Failed';
return;
}
@@ -225,12 +233,13 @@ async function syncLibraryFolders()
if (dataToInsert.length !== 0) {
let result = await db.insertBulk("jf_libraries",dataToInsert,jf_libraries_columns);
if (result.Result === "SUCCESS") {
socket.sendMessageToClients(dataToInsert.length + " Rows Inserted.");
loggedData.push(dataToInsert.length + " Rows Inserted.");
} else {
socket.sendMessageToClients({
loggedData.push({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
result='Failed';
}
}
@@ -238,27 +247,28 @@ async function syncLibraryFolders()
if (toDeleteIds.length > 0) {
let result = await db.deleteBulk("jf_libraries",toDeleteIds);
if (result.Result === "SUCCESS") {
socket.sendMessageToClients(toDeleteIds.length + " Rows Removed.");
loggedData.push(toDeleteIds.length + " Rows Removed.");
} else {
socket.sendMessageToClients({color: "red",Message: result.message,});
loggedData.push({color: "red",Message: result.message,});
result='Failed';
}
}
}
async function syncLibraryItems()
async function syncLibraryItems(loggedData,result)
{
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1' );
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
result='Failed';
return;
}
const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY);
socket.sendMessageToClients({ color: "lawngreen", Message: "Syncing... 1/3" });
socket.sendMessageToClients({color: "yellow",Message: "Beginning Library Item Sync",});
loggedData.push({ color: "lawngreen", Message: "Syncing... 1/3" });
loggedData.push({color: "yellow",Message: "Beginning Library Item Sync",});
const admins = await _sync.getAdminUser();
const userid = admins[0].Id;
@@ -301,10 +311,11 @@ async function syncLibraryItems()
if (result.Result === "SUCCESS") {
insertCounter += dataToInsert.length;
} else {
socket.sendMessageToClients({
loggedData.push({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
result='Failed';
}
}
@@ -314,28 +325,28 @@ async function syncLibraryItems()
if (result.Result === "SUCCESS") {
deleteCounter +=toDeleteIds.length;
} else {
socket.sendMessageToClients({color: "red",Message: result.message,});
loggedData.push({color: "red",Message: result.message,});
result='Failed';
}
}
socket.sendMessageToClients({color: "dodgerblue",Message: insertCounter + " Library Items Inserted.",});
socket.sendMessageToClients({color: "orange",Message: deleteCounter + " Library Items Removed.",});
socket.sendMessageToClients({ color: "yellow", Message: "Item Sync Complete" });
loggedData.push({color: "dodgerblue",Message: insertCounter + " Library Items Inserted.",});
loggedData.push({color: "orange",Message: deleteCounter + " Library Items Removed.",});
loggedData.push({ color: "yellow", Message: "Item Sync Complete" });
// const { rows: cleanup } = await db.query('DELETE FROM jf_playback_activity where "NowPlayingItemId" not in (select "Id" from jf_library_items)' );
// socket.sendMessageToClients({ color: "orange", Message: cleanup.length+" orphaned activity logs removed" });
}
async function syncShowItems()
async function syncShowItems(loggedData,result)
{
socket.sendMessageToClients({ color: "lawngreen", Message: "Syncing... 2/3" });
socket.sendMessageToClients({color: "yellow", Message: "Beginning Seasons and Episode sync",});
loggedData.push({ color: "lawngreen", Message: "Syncing... 2/3" });
loggedData.push({color: "yellow", Message: "Beginning Seasons and Episode sync",});
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
result='Failed';
return;
}
@@ -353,8 +364,7 @@ async function syncShowItems()
const allSeasons = await _sync.getSeasonsAndEpisodes(show.Id,'Seasons');
const allEpisodes =await _sync.getSeasonsAndEpisodes(show.Id,'Episodes');
show_counter++;
socket.sendMessageToClients({ Message: "Syncing shows " + (show_counter/shows.length*100).toFixed(2) +"%" ,key:'show_sync'});
loggedData.push({ Message: "Syncing shows " + (show_counter/shows.length*100).toFixed(2) +"%" ,key:'show_sync'});
const existingIdsSeasons = await db.query(`SELECT * FROM public.jf_library_seasons where "SeriesId" = '${show.Id}'`).then((res) => res.rows.map((row) => row.Id));
@@ -401,10 +411,11 @@ async function syncShowItems()
if (result.Result === "SUCCESS") {
insertSeasonsCount += seasonsToInsert.length;
} else {
socket.sendMessageToClients({
loggedData.push({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
result='Failed';
}
}
const toDeleteIds = existingIdsSeasons.filter((id) =>!allSeasons.some((row) => row.Id === id ));
@@ -414,7 +425,8 @@ async function syncShowItems()
if (result.Result === "SUCCESS") {
deleteSeasonsCount +=toDeleteIds.length;
} else {
socket.sendMessageToClients({color: "red",Message: result.message,});
loggedData.push({color: "red",Message: result.message,});
result='Failed';
}
}
@@ -425,10 +437,11 @@ async function syncShowItems()
if (result.Result === "SUCCESS") {
insertEpisodeCount += episodesToInsert.length;
} else {
socket.sendMessageToClients({
loggedData.push({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
result='Failed';
}
}
@@ -439,7 +452,8 @@ async function syncShowItems()
if (result.Result === "SUCCESS") {
deleteEpisodeCount +=toDeleteEpisodeIds.length;
} else {
socket.sendMessageToClients({color: "red",Message: result.message,});
loggedData.push({color: "red",Message: result.message,});
result='Failed';
}
}
@@ -447,22 +461,23 @@ async function syncShowItems()
}
socket.sendMessageToClients({color: "dodgerblue",Message: insertSeasonsCount + " Seasons inserted.",});
socket.sendMessageToClients({color: "orange",Message: deleteSeasonsCount + " Seasons Removed.",});
socket.sendMessageToClients({color: "dodgerblue",Message: insertEpisodeCount + " Episodes inserted.",});
socket.sendMessageToClients({color: "orange",Message: deleteEpisodeCount + " Episodes Removed.",});
socket.sendMessageToClients({ color: "yellow", Message: "Sync Complete" });
loggedData.push({color: "dodgerblue",Message: insertSeasonsCount + " Seasons inserted.",});
loggedData.push({color: "orange",Message: deleteSeasonsCount + " Seasons Removed.",});
loggedData.push({color: "dodgerblue",Message: insertEpisodeCount + " Episodes inserted.",});
loggedData.push({color: "orange",Message: deleteEpisodeCount + " Episodes Removed.",});
loggedData.push({ color: "yellow", Message: "Sync Complete" });
}
async function syncItemInfo()
async function syncItemInfo(loggedData,result)
{
socket.sendMessageToClients({ color: "lawngreen", Message: "Syncing... 3/3" });
socket.sendMessageToClients({color: "yellow", Message: "Beginning File Info Sync",});
loggedData.push({ color: "lawngreen", Message: "Syncing... 3/3" });
loggedData.push({color: "yellow", Message: "Beginning File Info Sync",});
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
result='Failed';
return;
}
@@ -499,10 +514,11 @@ async function syncItemInfo()
if (result.Result === "SUCCESS") {
insertItemInfoCount += ItemInfoToInsert.length;
} else {
socket.sendMessageToClients({
loggedData.push({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
result='Failed';
}
}
const toDeleteItemInfoIds = existingItemInfo.filter((id) =>!data.some((row) => row.Id === id ));
@@ -512,14 +528,14 @@ async function syncItemInfo()
if (result.Result === "SUCCESS") {
deleteItemInfoCount +=toDeleteItemInfoIds.length;
} else {
socket.sendMessageToClients({color: "red",Message: result.message,});
loggedData.push({color: "red",Message: result.message,});
result='Failed';
}
}
}
//loop for each Episode
console.log("Episode")
for (const Episode of Episodes) {
const data = await _sync.getItemInfo(Episode.EpisodeId,userid);
@@ -543,10 +559,11 @@ async function syncItemInfo()
if (result.Result === "SUCCESS") {
insertEpisodeInfoCount += EpisodeInfoToInsert.length;
} else {
socket.sendMessageToClients({
loggedData.push({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
result='Failed';
}
}
const toDeleteEpisodeInfoIds = existingEpisodeItemInfo.filter((id) =>!data.some((row) => row.Id === id ));
@@ -556,18 +573,19 @@ async function syncItemInfo()
if (result.Result === "SUCCESS") {
deleteEpisodeInfoCount +=toDeleteEpisodeInfoIds.length;
} else {
socket.sendMessageToClients({color: "red",Message: result.message,});
loggedData.push({color: "red",Message: result.message,});
result='Failed';
}
}
console.log(Episode.Name)
}
socket.sendMessageToClients({color: "dodgerblue",Message: insertItemInfoCount + " Item Info inserted.",});
socket.sendMessageToClients({color: "orange",Message: deleteItemInfoCount + " Item Info Removed.",});
socket.sendMessageToClients({color: "dodgerblue",Message: insertEpisodeInfoCount + " Episodes Info inserted.",});
socket.sendMessageToClients({color: "orange",Message: deleteEpisodeInfoCount + " Episodes Info Removed.",});
socket.sendMessageToClients({ color: "lawngreen", Message: "Sync Complete" });
loggedData.push({color: "dodgerblue",Message: insertItemInfoCount + " Item Info inserted.",});
loggedData.push({color: "orange",Message: deleteItemInfoCount + " Item Info Removed.",});
loggedData.push({color: "dodgerblue",Message: insertEpisodeInfoCount + " Episodes Info inserted.",});
loggedData.push({color: "orange",Message: deleteEpisodeInfoCount + " Episodes Info Removed.",});
loggedData.push({ color: "lawngreen", Message: "Sync Complete" });
}
async function syncPlaybackPluginData()
@@ -633,20 +651,74 @@ async function syncPlaybackPluginData()
}
async function fullSync()
{
let startTime = moment();
let loggedData=[];
let result='Success';
await syncUserData(loggedData,result);
await syncLibraryFolders(loggedData,result);
await syncLibraryItems(loggedData,result);
await syncShowItems(loggedData,result);
await syncItemInfo(loggedData,result);
const uuid = randomUUID();
let endTime = moment();
let diffInSeconds = endTime.diff(startTime, 'seconds');
const log=
{
"Id":uuid,
"Name":"Jellyfin Sync",
"Type":"Task",
"ExecutionType":"Automatic",
"Duration":diffInSeconds,
"TimeRun":startTime,
"Log":JSON.stringify(loggedData),
"Result":result
};
logging.insertLog(log);
}
////////////////////////////////////////API Calls
///////////////////////////////////////Sync All
router.get("/beingSync", async (req, res) => {
socket.clearMessages();
let loggedData=[];
let result='Success';
await syncUserData();
await syncLibraryFolders();
await syncLibraryItems();
await syncShowItems();
await syncItemInfo();
let startTime = moment();
await syncUserData(loggedData,result);
await syncLibraryFolders(loggedData,result);
await syncLibraryItems(loggedData,result);
await syncShowItems(loggedData,result);
await syncItemInfo(loggedData,result);
const uuid = randomUUID();
let endTime = moment();
let diffInSeconds = endTime.diff(startTime, 'seconds');
const log=
{
"Id":uuid,
"Name":"Jellyfin Sync",
"Type":"Task",
"ExecutionType":"Manual",
"Duration":diffInSeconds,
"TimeRun":startTime,
"Log":JSON.stringify(loggedData),
"Result":result
};
logging.insertLog(log);
res.send();
});
@@ -702,4 +774,5 @@ router.get("/syncPlaybackPluginData", async (req, res) => {
module.exports = router;
module.exports =
{router,fullSync};

View File

@@ -0,0 +1,62 @@
const db = require("../db");
const Logging = require("../logging");
const backup = require("../backup");
const moment = require('moment');
const { randomUUID } = require('crypto');
async function BackupTask(interval) {
console.log("Backup Interval: " + interval);
setInterval(async () => {
try {
const { rows: config } = await db.query(
'SELECT * FROM app_config where "ID"=1'
);
if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
return;
}
let startTime = moment();
let logData=[];
let result='Success';
await backup.backup(logData,result);
let endTime = moment();
let diffInSeconds = endTime.diff(startTime, 'seconds');
const uuid = randomUUID();
const log=
{
"Id":uuid,
"Name":"Backup",
"Type":"Task",
"ExecutionType":"Automatic",
"Duration":diffInSeconds,
"TimeRun":startTime,
"Log":JSON.stringify(logData),
"Result":result
};
Logging.insertLog(log);
} catch (error) {
// console.log(error);
return [];
}
}, interval);
}
module.exports = {
BackupTask,
};

35
backend/tasks/SyncTask.js Normal file
View File

@@ -0,0 +1,35 @@
const db = require("../db");
const sync = require("../sync");
async function SyncTask(interval) {
console.log("LibraryMonitor Interval: " + interval);
setInterval(async () => {
try {
const { rows: config } = await db.query(
'SELECT * FROM app_config where "ID"=1'
);
if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
return;
}
sync.fullSync();
} catch (error) {
// console.log(error);
return [];
}
}, interval);
}
module.exports = {
SyncTask,
};

View File

@@ -1,33 +1,43 @@
const GitHub = require('github-api');
const packageJson = require('../package.json');
const {compareVersions} =require('compare-versions');
async function checkForUpdates() {
const currentVersion = packageJson.version;
const repoOwner = 'cyfershepard';
const repoName = 'jellystat';
const repoName = 'Jellystat';
const gh = new GitHub();
const repo = gh.getRepo(repoOwner, repoName);
let result={current_version: packageJson.version, latest_version:'', message:'', update_available:false};
let latestVersion;
try {
const releases = await repo.listReleases();
const path = 'package.json';
if (releases.data.length > 0) {
latestVersion = releases.data[0].tag_name;
console.log(releases.data);
const response = await gh.getRepo(repoOwner, repoName).getContents('main', path);
const content = response.data.content;
const decodedContent = Buffer.from(content, 'base64').toString();
latestVersion = JSON.parse(decodedContent).version;
if (compareVersions(latestVersion,currentVersion) > 0) {
// console.log(`A new version V.${latestVersion} of ${repoName} is available.`);
result = { current_version: packageJson.version, latest_version: latestVersion, message: `${repoName} has an update ${latestVersion}`, update_available:true };
} else if (compareVersions(latestVersion,currentVersion) < 0) {
// console.log(`${repoName} is using a beta version.`);
result = { current_version: packageJson.version, latest_version: latestVersion, message: `${repoName} is using a beta version`, update_available:false };
} else {
// console.log(`${repoName} is up to date.`);
result = { current_version: packageJson.version, latest_version: latestVersion, message: `${repoName} is up to date`, update_available:false };
}
} catch (error) {
console.error(`Failed to fetch releases for ${repoName}: ${error.message}`);
result = { current_version: packageJson.version, latest_version: 'N/A', message: `Failed to fetch releases for ${repoName}: ${error.message}`, update_available:false };
}
if (latestVersion && latestVersion !== currentVersion) {
console.log(`A new version (${latestVersion}) of ${repoName} is available.`);
} else if (latestVersion) {
console.log(`${repoName} is up to date.`);
}
else {
console.log(`Unable to retrieve latest version`);
}
return result;
}
module.exports = { checkForUpdates };

10
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jfstat",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jfstat",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
@@ -22,6 +22,7 @@
"antd": "^5.3.0",
"axios": "^1.3.4",
"bootstrap": "^5.2.3",
"compare-versions": "^6.0.0-rc.1",
"concurrently": "^7.6.0",
"cors": "^2.8.5",
"crypto-js": "^4.1.1",
@@ -7150,6 +7151,11 @@
"version": "1.0.1",
"license": "MIT"
},
"node_modules/compare-versions": {
"version": "6.0.0-rc.1",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.0.0-rc.1.tgz",
"integrity": "sha512-cFhkjbGY1jLFWIV7KegECbfuyYPxSGvgGkdkfM+ibboQDoPwg2FRHm5BSNTOApiauRBzJIQH7qvOJs2sW5ueKQ=="
},
"node_modules/compressible": {
"version": "2.0.18",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "jfstat",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.10.6",
@@ -17,6 +17,7 @@
"antd": "^5.3.0",
"axios": "^1.3.4",
"bootstrap": "^5.2.3",
"compare-versions": "^6.0.0-rc.1",
"concurrently": "^7.6.0",
"cors": "^2.8.5",
"crypto-js": "^4.1.1",

View File

@@ -1,8 +1,11 @@
@import 'pages/css/variables.module.css';
main{
margin-inline: 20px;
margin-inline: 20px;
/* width: 100%; */
overflow: auto;
}
.App-logo {
height: 40vmin;
pointer-events: none;
@@ -82,26 +85,24 @@ h2{
.btn-outline-primary
{
color: grey!important;
color: white!important;
border-color: var(--primary-color) !important;
background-color: var(--background-color) !important;
}
.btn-outline-primary:hover
{
color: white !important;
background-color: var(--primary-color) !important;
}
.btn-outline-primary.active
{
color: white !important;
background-color: var(--primary-color) !important;
}
.btn-outline-primary:focus
{
color: white !important;
background-color: var(--primary-color) !important;
}

View File

@@ -22,6 +22,7 @@ import Libraries from './pages/libraries';
import LibraryInfo from './pages/components/library-info';
import ItemInfo from './pages/components/item-info';
import ErrorPage from './pages/components/general/error';
import About from './pages/about';
import Testing from './pages/testing';
@@ -116,9 +117,10 @@ if (config && config.apiKey ===null) {
if (config && isConfigured && token!==null){
return (
<div className="App">
<Navbar />
<div>
<main>
<div className='d-flex flex-column flex-md-row'>
<Navbar/>
<main className='w-md-100'>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/settings" element={<Settings />} />
@@ -130,6 +132,7 @@ if (config && isConfigured && token!==null){
<Route path="/statistics" element={<Statistics />} />
<Route path="/activity" element={<Activity />} />
<Route path="/testing" element={<Testing />} />
<Route path="/about" element={<About />} />
</Routes>
</main>
</div>

View File

@@ -14,6 +14,14 @@ body {
color: white ;
}
*
{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',sans-serif !important;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;

View File

@@ -7,6 +7,7 @@ import HistoryFillIcon from 'remixicon-react/HistoryFillIcon';
import SettingsFillIcon from 'remixicon-react/SettingsFillIcon';
import GalleryFillIcon from 'remixicon-react/GalleryFillIcon';
import UserFillIcon from 'remixicon-react/UserFillIcon';
import InformationFillIcon from 'remixicon-react/InformationFillIcon';
// import ReactjsFillIcon from 'remixicon-react/ReactjsFillIcon';
@@ -16,7 +17,7 @@ export const navData = [
id: 0,
icon: <HomeFillIcon/>,
text: "Home",
link: "/"
link: ""
},
{
id: 1,
@@ -49,13 +50,14 @@ export const navData = [
text: "Settings",
link: "settings"
}
,
{
id: 7,
icon: <InformationFillIcon />,
text: "About",
link: "about"
}
]
// {
// id: 5,
// icon: <ReactjsFillIcon />,
// text: "Component Testing Playground",
// link: "testing"
// }
// ,

91
src/pages/about.js Normal file
View File

@@ -0,0 +1,91 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
// import Button from "react-bootstrap/Button";
// import Card from 'react-bootstrap/Card';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Loading from "./components/general/loading";
import "./css/about.css";
import { Card } from "react-bootstrap";
export default function SettingsAbout() {
const token = localStorage.getItem('token');
const [data, setData] = useState();
useEffect(() => {
const fetchVersion = () => {
if (token) {
const url = `/api/CheckForUpdates`;
axios
.get(url, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((data) => {
setData(data.data);
})
.catch((error) => {
console.log(error);
});
}
};
if(!data)
{
fetchVersion();
}
const intervalId = setInterval(fetchVersion, 60000 * 5);
return () => clearInterval(intervalId);
}, [token]);
if(!data)
{
return <Loading/>;
}
return (
<div className="tasks">
<h1 className="py-3">About Jellystat</h1>
<Card className="about p-0" >
<Card.Body >
<Row>
<Col className="px-0">
Version:
</Col>
<Col>
{data.current_version}
</Col>
</Row>
<Row style={{color:(data.update_available ? "#00A4DC": "White")}}>
<Col className="px-0">
Update Available:
</Col>
<Col>
{data.message}
</Col>
</Row>
<Row style={{height:'20px'}}></Row>
<Row>
<Col className="px-0">
Github:
</Col>
<Col>
<a href="https://github.com/CyferShepard/Jellystat" target="_blank" > https://github.com/CyferShepard/Jellystat</a>
</Col>
</Row>
</Card.Body>
</Card>
</div>
);
}

View File

@@ -10,43 +10,46 @@ 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 '../../css/activity/activity-table.css';
// localStorage.setItem('hour12',true);
let hour_format = Boolean(localStorage.getItem('hour12'));
function formatTotalWatchTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
const hours = Math.floor(seconds / 3600); // 1 hour = 3600 seconds
const minutes = Math.floor((seconds % 3600) / 60); // 1 minute = 60 seconds
let formattedTime='';
if(hours)
{
formattedTime+=`${hours} hours`;
}
if(minutes)
{
formattedTime+=` ${minutes} minutes`;
let timeString = '';
if (hours > 0) {
timeString += `${hours} ${hours === 1 ? 'hr' : 'hrs'} `;
}
if(!hours && !minutes)
{
// const seconds = Math.floor(((seconds % 3600) / 60) / 60); // 1 minute = 60 seconds
formattedTime+=` ${seconds} seconds`;
if (minutes > 0) {
timeString += `${minutes} ${minutes === 1 ? 'min' : 'mins'} `;
}
return formattedTime ;
if (remainingSeconds > 0) {
timeString += `${remainingSeconds} ${remainingSeconds === 1 ? 'second' : 'seconds'}`;
}
return timeString.trim();
}
function Row(data) {
const { row } = data;
const [open, setOpen] = React.useState(false);
// const classes = useRowStyles();
const twelve_hr = JSON.parse(localStorage.getItem('12hr'));
const options = {
day: "numeric",
@@ -55,11 +58,10 @@ function Row(data) {
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: hour_format,
hour12: twelve_hr,
};
return (
<React.Fragment>
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>
@@ -76,7 +78,8 @@ function Row(data) {
<TableCell><Link to={`/libraries/item/${row.EpisodeId || row.NowPlayingItemId}`} className='text-decoration-none'>{!row.SeriesName ? row.NowPlayingItemName : row.SeriesName+' - '+ row.NowPlayingItemName}</Link></TableCell>
<TableCell>{row.Client}</TableCell>
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(row.ActivityDateInserted))}</TableCell>
<TableCell>{formatTotalWatchTime(row.PlaybackDuration) || '0 minutes'}</TableCell>
{/* <TableCell>{formatTotalWatchTime(row.results && row.results.length>0 ? row.results.reduce((acc, items) => acc +parseInt(items.PlaybackDuration),0): row.PlaybackDuration) || '0 minutes'}</TableCell> */}
<TableCell>{formatTotalWatchTime(row.PlaybackDuration) || '0 seconds'}</TableCell>
<TableCell>{row.results.length !==0 ? row.results.length : 1}</TableCell>
</TableRow>
<TableRow>
@@ -87,7 +90,7 @@ function Row(data) {
<Table aria-label="sub-activity" className='rounded-2'>
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>User</TableCell>
<TableCell>Title</TableCell>
<TableCell>Client</TableCell>
<TableCell>Date</TableCell>
@@ -96,14 +99,14 @@ function Row(data) {
</TableRow>
</TableHead>
<TableBody>
{row.results.map((resultRow) => (
{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><Link to={`/libraries/item/${resultRow.EpisodeId || resultRow.NowPlayingItemId}`} className='text-decoration-none'>{!resultRow.SeriesName ? resultRow.NowPlayingItemName : resultRow.SeriesName+' - '+ resultRow.NowPlayingItemName}</Link></TableCell>
<TableCell>{resultRow.Client}</TableCell>
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(resultRow.ActivityDateInserted))}</TableCell>
<TableCell>{formatTotalWatchTime(resultRow.PlaybackDuration) || '0 minutes'}</TableCell>
<TableCell>{formatTotalWatchTime(resultRow.PlaybackDuration) || '0 seconds'}</TableCell>
<TableCell>1</TableCell>
</TableRow>
))}
@@ -117,10 +120,90 @@ function Row(data) {
);
}
function EnhancedTableHead(props) {
const { order, orderBy, onRequestSort } =
props;
const createSortHandler = (property) => (event) => {
onRequestSort(event, property);
};
const headCells = [
{
id: 'UserName',
numeric: false,
disablePadding: true,
label: 'Last User',
},
{
id: 'NowPlayingItemName',
numeric: false,
disablePadding: false,
label: 'Title',
},
{
id: 'Client',
numeric: false,
disablePadding: false,
label: 'Last Client',
},
{
id: 'ActivityDateInserted',
numeric: false,
disablePadding: false,
label: 'Date',
},
{
id: 'PlaybackDuration',
numeric: false,
disablePadding: false,
label: 'Total Playback',
},
{
id: 'TotalPlays',
numeric: false,
disablePadding: false,
label: 'TotalPlays',
},
];
return (
<TableHead>
<TableRow>
<TableCell/>
{headCells.map((headCell) => (
<TableCell
key={headCell.id}
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'}
onClick={createSortHandler(headCell.id)}
>
{headCell.label}
{orderBy === headCell.id ? (
<Box component="span" sx={visuallyHidden}>
{order === 'desc' ? 'sorted descending' : 'sorted ascending'}
</Box>
) : null}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
);
}
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');
if(rowsPerPage!==props.itemCount)
{
@@ -137,25 +220,70 @@ export default function ActivityTable(props) {
setPage((prevPage) => prevPage - 1);
};
function descendingComparator(a, b, orderBy) {
if (b[orderBy] < a[orderBy]) {
return -1;
}
if (b[orderBy] > a[orderBy]) {
return 1;
}
return 0;
}
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;
}
return a[1] - b[1];
});
return stabilizedThis.map((el) => el[0]);
}
const visibleRows = React.useMemo(
() =>
stableSort(props.data, getComparator(order, orderBy)).slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage,
),
[order, orderBy, page, rowsPerPage, getComparator, props.data],
);
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" >
<TableHead>
<TableRow>
<TableCell />
<TableCell>Username</TableCell>
<TableCell>Title</TableCell>
<TableCell>Client</TableCell>
<TableCell>Date</TableCell>
<TableCell>Playback Duration</TableCell>
<TableCell>Plays</TableCell>
</TableRow>
</TableHead>
<EnhancedTableHead
order={order}
orderBy={orderBy}
onRequestSort={handleRequestSort}
rowCount={rowsPerPage}
/>
<TableBody>
{props.data
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row) => (
{visibleRows.map((row) => (
<Row key={row.Id+row.NowPlayingItemId+row.EpisodeId} row={row} />
))}
{props.data.length===0 ? <tr><td colSpan="7" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}} className='py-2'>No Activity Found</td></tr> :''}

View File

@@ -35,7 +35,7 @@ function LastWatchedCard(props) {
<div className="last-card">
<Link to={`/libraries/item/${props.data.EpisodeId||props.data.Id}`}>
<div className="last-card-banner">
{loaded ? null : <Blurhash hash={props.data.PrimaryImageHash} width={'100%'} height={'100%'}/>}
{loaded ? null : <Blurhash hash={props.data.PrimaryImageHash} width={'100%'} height={'100%'} className="rounded-3 overflow-hidden"/>}
<img
src={
`${

View File

@@ -1,9 +1,11 @@
import { Nav, Navbar as BootstrapNavbar, Container } from "react-bootstrap";
import { Nav, Navbar as BootstrapNavbar } from "react-bootstrap";
import { Link, useLocation } from "react-router-dom";
import { navData } from "../../../lib/navdata";
import LogoutBoxLineIcon from "remixicon-react/LogoutBoxLineIcon";
import logo_dark from '../../images/icon-b-512.png';
import "../../css/navbar.css";
import React from "react";
import VersionCard from "./version-card";
export default function Navbar() {
const handleLogout = () => {
@@ -11,36 +13,44 @@ export default function Navbar() {
window.location.reload();
};
const location = useLocation(); // use the useLocation hook from react-router-dom
const location = useLocation();
return (
<BootstrapNavbar variant="dark" expand="md" className="navbar py-0">
<Container fluid>
<BootstrapNavbar.Brand as={Link} to={"/"}><img src={logo_dark} style={{height:"32px"}} className="px-2"/>Jellystat</BootstrapNavbar.Brand>
<BootstrapNavbar.Toggle aria-controls="responsive-navbar-nav" />
<BootstrapNavbar.Collapse id="responsive-navbar-nav">
<Nav className="ms-auto">
{navData.map((item) => {
const isActive = location.pathname.toLocaleLowerCase().includes(('/'+item.link).toLocaleLowerCase()); // check if the link is the current path
return (
<Nav.Link
as={Link}
key={item.id}
className={`navitem${isActive ? " active" : ""}`} // add the "active" class if the link is active
to={item.link}
>
{item.icon}
<span className="nav-text">{item.text}</span>
</Nav.Link>
);
})}
<Nav.Link className="navitem" href="#logout" onClick={handleLogout}>
<LogoutBoxLineIcon />
<span className="nav-text">Logout</span>
</Nav.Link>
</Nav>
</BootstrapNavbar.Collapse>
</Container>
<BootstrapNavbar variant="dark" className=" d-flex flex-column py-0 text-center sticky-top">
<div className="sticky-top py-md-3">
<BootstrapNavbar.Brand as={Link} to={"/"} className="d-none d-md-inline">
<img src={logo_dark} style={{height:"52px"}} className="px-2" alt=''/>
<span>Jellystat</span>
</BootstrapNavbar.Brand>
<Nav className="flex-row flex-md-column w-100">
{navData.map((item) => {
const locationString=location.pathname.toLocaleLowerCase();
const isActive = locationString.includes(('/'+item.link).toLocaleLowerCase()) && ((locationString.length>0 && item.link.length>0) || (locationString.length===1 && item.link.length===0)); // check if the link is the current path
return (
<Nav.Link
as={Link}
key={item.id}
className={`navitem${isActive ? " active" : ""} p-2`} // add the "active" class if the link is active
to={item.link}
>
{item.icon}
<span className="d-none d-md-block nav-text">{item.text}</span>
</Nav.Link>
);
})}
<Nav.Link className="navitem p-2 logout" href="#logout" onClick={handleLogout}>
<LogoutBoxLineIcon />
<span className="d-none d-md-block nav-text">Logout</span>
</Nav.Link>
</Nav>
</div>
<VersionCard/>
</BootstrapNavbar>
);
}

View File

@@ -0,0 +1,71 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import "../../css/settings/version.css";
import { Card } from "react-bootstrap";
export default function VersionCard() {
const token = localStorage.getItem('token');
const [data, setData] = useState();
useEffect(() => {
const fetchVersion = () => {
if (token) {
const url = `/api/CheckForUpdates`;
axios
.get(url, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((data) => {
setData(data.data);
})
.catch((error) => {
console.log(error);
});
}
};
if(!data)
{
fetchVersion();
}
const intervalId = setInterval(fetchVersion, 60000 * 5);
return () => clearInterval(intervalId);
}, [data,token]);
if(!data)
{
return <></>;
}
return (
<Card className="d-none d-md-block version rounded-0 border-0" >
<Card.Body>
<Row>
<Col>Jellystat {data.current_version}</Col>
</Row>
{data.update_available?
<Row>
<Col ><a href="https://github.com/CyferShepard/Jellystat" target="_blank" style={{color:'#00A4DC'}}>New version available: {data.latest_version}</a></Col>
</Row>
:
<></>
}
</Card.Body>
</Card>
);
}

View File

@@ -117,18 +117,30 @@ if(data && data.notfound)
return <ErrorPage message={data.message}/>;
}
const cardStyle = {
backgroundImage: `url(/Proxy/Items/Images/Backdrop?id=${(["Episode","Season"].includes(data.Type)? data.SeriesId : data.Id)}&fillWidth=800&quality=90)`,
height:'100%',
backgroundSize: 'cover',
};
const cardBgStyle = {
backgroundColor: 'rgb(0, 0, 0, 0.8)',
};
return (
<div>
<div className="item-detail-container">
<Row className="justify-content-center justify-content-md-start">
<Col className="col-auto my-4 my-md-0">
{data.PrimaryImageHash && !loaded ? <Blurhash hash={data.PrimaryImageHash} width={'200px'} height={'300px'}/> : null}
<div className="item-detail-container rounded-3" style={cardStyle}>
<Row className="justify-content-center justify-content-md-start rounded-3 g-0 p-4" style={cardBgStyle}>
<Col className="col-auto my-4 my-md-0 item-banner-image" >
{data.PrimaryImageHash && !loaded ? <Blurhash hash={data.PrimaryImageHash} width={'200px'} height={'300px'} className="rounded-3 overflow-hidden" style={{display:'block'}}/> : null}
<img
className="item-image"
src={
"/Proxy/Items/Images/Primary?id=" +
(data.Type==="Episode"? data.SeriesId : data.Id) +
(["Episode","Season"].includes(data.Type)? data.SeriesId : data.Id) +
"&fillWidth=200&quality=90"
}
alt=""

View File

@@ -16,7 +16,7 @@ function MoreItemCards(props) {
<div className={props.data.Type==="Episode" ? "last-card episode-card" : "last-card"}>
<Link to={`/libraries/item/${ (props.data.Type==="Episode" ? props.data.EpisodeId : props.data.Id) }`}>
<div className={props.data.Type==="Episode" ? "last-card-banner episode" : "last-card-banner"}>
{(props.data.ImageBlurHashes || props.data.PrimaryImageHash )&& !loaded ? <Blurhash hash={props.data.PrimaryImageHash || props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary] } width={'100%'} height={'100%'}/> : null}
{(props.data.ImageBlurHashes || props.data.PrimaryImageHash )&& !loaded ? <Blurhash hash={props.data.PrimaryImageHash || props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary] } width={'100%'} height={'100%'} className="rounded-3 overflow-hidden"/> : null}
{fallback ?
<img

View File

@@ -9,7 +9,7 @@ import FilmLineIcon from "remixicon-react/FilmLineIcon";
import Loading from './general/loading';
import LibraryGlobalStats from './library/library-stats';
import LibraryLastWatched from './library/last-watched';
import RecentlyPlayed from './library/recently-added';
import RecentlyAdded from './library/recently-added';
import LibraryActivity from './library/library-activity';
import LibraryItems from './library/library-items';
@@ -84,7 +84,7 @@ function LibraryInfo() {
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant='pills'>
<Tab eventKey="tabOverview" className='bg-transparent'>
<LibraryGlobalStats LibraryId={LibraryId}/>
<RecentlyPlayed LibraryId={LibraryId}/>
<RecentlyAdded LibraryId={LibraryId}/>
<LibraryLastWatched LibraryId={LibraryId}/>
</Tab>
<Tab eventKey="tabActivity" className='bg-transparent'>

View File

@@ -12,7 +12,7 @@ function RecentlyAddedCard(props) {
<div className="last-card">
<Link to={`/libraries/item/${props.data.Id}`}>
<div className="last-card-banner">
{loaded ? null : <Blurhash hash={props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary]} width={'100%'} height={'100%'}/>}
{loaded ? null : <Blurhash hash={props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary]} width={'100%'} height={'100%'} className="rounded-3 overflow-hidden"/>}
<img
src={
`${"/Proxy/Items/Images/Primary?id=" +

View File

@@ -74,7 +74,7 @@ function LibraryCard(props) {
return `${formattedTime}ago`;
}
return (
<Card className="bg-transparent lib-card border-0">
<Card className="bg-transparent lib-card rounded-3">
<Link to={`/libraries/${props.data.Id}`}>
<div className="library-card-image">
<Card.Img

View File

@@ -9,6 +9,7 @@ import MoreItemCards from "../item-info/more-items/more-items-card";
import Config from "../../../lib/config";
import "../../css/library/media-items.css";
import "../../css/width_breakpoint_css.css";
function LibraryItems(props) {
const [data, setData] = useState();
@@ -70,10 +71,10 @@ function LibraryItems(props) {
}
return (
<div className="last-played">
<div className="library-items">
<div className="d-md-flex justify-content-between">
<h1 className="my-3">Media</h1>
<FormControl type="text" placeholder="Search" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="my-3 w-25" />
<FormControl type="text" placeholder="Search" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="my-3 w-sm-100 w-md-75 w-lg-25" />
</div>
<div className="media-items-container">

View File

@@ -6,7 +6,7 @@ import RecentlyAddedCard from "./RecentlyAdded/recently-added-card";
import Config from "../../../lib/config";
import "../../css/users/user-details.css";
function RecentlyPlayed(props) {
function RecentlyAdded(props) {
const [data, setData] = useState();
const [config, setConfig] = useState();
@@ -22,33 +22,23 @@ function RecentlyPlayed(props) {
}
};
// const fetchAdmin = async () => {
// try {
// let url=`/api/getAdminUsers`;
// const adminData = await axios.get(url, {
// headers: {
// Authorization: `Bearer ${config.token}`,
// "Content-Type": "application/json",
// },
// });
// return adminData.data[0].Id;
// // setData(itemData.data);
// } catch (error) {
// console.log(error);
// }
// };
const fetchData = async () => {
try {
// let adminId=await fetchAdmin();
let url=`/stats/getRecentlyAdded?libraryid=${props.LibraryId}`;
let url=`/stats/getRecentlyAdded`;
if(props.LibraryId)
{
url+=`?libraryid=${props.LibraryId}`;
}
const itemData = await axios.get(url, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
});
setData(itemData.data);
setData(itemData.data.filter((item) => ["Series", "Movie","Audio"].includes(item.Type)));
} catch (error) {
console.log(error);
}
@@ -75,7 +65,7 @@ function RecentlyPlayed(props) {
<div className="last-played">
<h1 className="my-3">Recently Added</h1>
<div className="last-played-container">
{data.filter((item) => ["Series", "Movie","Audio"].includes(item.Type)).map((item) => (
{data && data.map((item) => (
<RecentlyAddedCard data={item} base_url={config.hostUrl} key={item.Id}/>
))}
@@ -85,4 +75,4 @@ function RecentlyPlayed(props) {
);
}
export default RecentlyPlayed;
export default RecentlyAdded;

View File

@@ -11,8 +11,8 @@ import FilmLineIcon from "remixicon-react/FilmLineIcon";
export default function LibraryOverView() {
const token = localStorage.getItem('token');
const SeriesIcon=<TvLineIcon size={"80%"} /> ;
const MovieIcon=<FilmLineIcon size={"80%"} /> ;
const SeriesIcon=<TvLineIcon size={"100%"} /> ;
const MovieIcon=<FilmLineIcon size={"100%"} /> ;
const [data, setData] = useState();
@@ -41,7 +41,7 @@ export default function LibraryOverView() {
return (
<div>
<h1 className="my-3">Library Statistics</h1>
<h1 className="my-3">Library Overview</h1>
<div className="overview-container">
<LibraryStatComponent data={data.filter((stat) => stat.CollectionType === "movies")} heading={"MOVIE LIBRARIES"} units={"MOVIES"} icon={MovieIcon}/>

View File

@@ -1,4 +1,5 @@
import React from "react";
import { Link } from "react-router-dom";
import { Row, Col, Card } from "react-bootstrap";
function LibraryStatComponent(props) {
@@ -48,7 +49,7 @@ function LibraryStatComponent(props) {
<div className="d-flex justify-content-between">
<Card.Text className="stat-item-index m-0">{index + 1}</Card.Text>
<Card.Text>{item.Name}</Card.Text>
<Link to={`/libraries/${item.Id}`}><Card.Text>{item.Name}</Card.Text></Link>
</div>
<Card.Text className="stat-item-count">

View File

@@ -67,17 +67,13 @@ function sessionCard(props) {
<img
className="card-device-image"
src={
props.data.base_url +
"/web/assets/img/devices/"
"/proxy/web/assets/img/devices/?devicename="
+
(props.data.session.Client.toLowerCase().includes("web") ?
( clientData.find(item => props.data.session.DeviceName.toLowerCase().includes(item)) || "other")
:
( clientData.find(item => props.data.session.Client.toLowerCase().includes(item)) || "other")
)
+
".svg"
}
)}
alt=""
/>
</Col>
@@ -126,6 +122,22 @@ function sessionCard(props) {
</Col>
</Row>
{props.data.session.NowPlayingItem.ParentIndexNumber ?
<Row>
<Col className="col-auto">
<Card.Text className="text-end">
{'S'+props.data.session.NowPlayingItem.ParentIndexNumber +' - E'+ props.data.session.NowPlayingItem.IndexNumber}
</Card.Text>
</Col>
</Row>
:
<></>
}
<Row className="d-flex">
<Col className="col-auto">

View File

@@ -40,7 +40,7 @@ function Sessions() {
},
})
.then((data) => {
setData(data.data);
setData(data.data.filter(row => row.NowPlayingItem !== undefined));
})
.catch((error) => {
console.log(error);
@@ -79,9 +79,8 @@ function Sessions() {
<div>
<h1 className="my-3">Sessions</h1>
<div className="sessions-container">
{data &&
{data && data.length>0 &&
data
.filter(row => row.NowPlayingItem !== undefined)
.sort((a, b) =>
a.Id.padStart(12, "0").localeCompare(b.Id.padStart(12, "0"))
)

View File

@@ -0,0 +1,108 @@
import React, { useState } from "react";
import axios from "axios";
import Button from "react-bootstrap/Button";
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 "../../css/settings/settings.css";
export default function Tasks() {
const [processing, setProcessing] = useState(false);
const token = localStorage.getItem('token');
async function beginSync() {
setProcessing(true);
await axios
.get("/sync/beingSync", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
setProcessing(false);
// return { isValid: isValid, errorMessage: errorMessage };
}
async function createBackup() {
setProcessing(true);
await axios
.get("/data/backup", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
setProcessing(false);
// return { isValid: isValid, errorMessage: errorMessage };
}
const handleClick = () => {
beginSync();
console.log('Button clicked!');
}
return (
<div className="tasks">
<h1 className="py-3">Tasks</h1>
<TableContainer className='rounded-2'>
<Table aria-label="collapsible table" >
<TableHead>
<TableRow>
<TableCell>Task</TableCell>
<TableCell>Type</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>Synchronize with Jellyfin</TableCell>
<TableCell>Import</TableCell>
<TableCell className="d-flex justify-content-center"> <Button variant={!processing ? "outline-primary" : "outline-light"} disabled={processing} onClick={handleClick}>Start</Button></TableCell>
</TableRow>
<TableRow>
<TableCell>Backup Jellystat</TableCell>
<TableCell>Process</TableCell>
<TableCell className="d-flex justify-content-center"><Button variant={!processing ? "outline-primary" : "outline-light"} disabled={processing} onClick={createBackup}>Start</Button></TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</div>
);
}

View File

@@ -1,30 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import '../../css/websocket/websocket.css';
const TerminalComponent = () => {
const [messages, setMessages] = useState([]);
function TerminalComponent(props){
const [messages] = useState(props.data);
useEffect(() => {
try{
const socket = new WebSocket(`ws://127.0.0.1:${process.env.WS_PORT || 3004}`);
// handle incoming messages
socket.addEventListener('message', (event) => {
let message = JSON.parse(event.data);
setMessages(message);
});
return () => {
socket.close();
}
}catch(error)
{
// console.log(error);
}
}, []);
return (
<div className='my-4'>

View File

@@ -111,6 +111,8 @@ function Row(file) {
const twelve_hr = JSON.parse(localStorage.getItem('12hr'));
const options = {
day: "numeric",
month: "numeric",
@@ -118,10 +120,11 @@ function Row(file) {
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: false,
hour12: twelve_hr,
};
return (
<React.Fragment>
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>

View File

@@ -1,97 +0,0 @@
import React, { useState } from "react";
import axios from "axios";
import Button from "react-bootstrap/Button";
import Form from 'react-bootstrap/Form';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import "../../css/settings/settings.css";
export default function LibrarySync() {
const [processing, setProcessing] = useState(false);
const token = localStorage.getItem('token');
async function beginSync() {
setProcessing(true);
await axios
.get("/sync/beingSync", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
setProcessing(false);
// return { isValid: isValid, errorMessage: errorMessage };
}
async function createBackup() {
setProcessing(true);
await axios
.get("/data/backup", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
setProcessing(false);
// return { isValid: isValid, errorMessage: errorMessage };
}
const handleClick = () => {
beginSync();
console.log('Button clicked!');
}
return (
<div className="tasks">
<h1 >Tasks</h1>
<Row className="mb-3">
<Form.Label column sm="2">
Synchronize with Jellyfin
</Form.Label>
<Col sm="10">
<Button variant={!processing ? "outline-primary" : "outline-light"} disabled={processing} onClick={handleClick}>Start</Button>
</Col>
</Row>
<Row className="mb-3">
<Form.Label column sm="2">
Create Backup
</Form.Label>
<Col sm="10">
<Button variant={!processing ? "outline-primary" : "outline-light"} disabled={processing} onClick={createBackup}>Start</Button>
</Col>
</Row>
</div>
);
}

View File

@@ -0,0 +1,210 @@
import React, { useEffect } from "react";
import axios from "axios";
import {ButtonGroup, Button } 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 IconButton from '@mui/material/IconButton';
import Box from '@mui/material/Box';
import AddCircleFillIcon from 'remixicon-react/AddCircleFillIcon';
import IndeterminateCircleFillIcon from 'remixicon-react/IndeterminateCircleFillIcon';
import "../../css/settings/backups.css";
import TerminalComponent from "./TerminalComponent";
const token = localStorage.getItem('token');
function Row(logs) {
const { data } = logs;
const [open, setOpen] = React.useState(false);
const twelve_hr = JSON.parse(localStorage.getItem('12hr'));
const options = {
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: twelve_hr,
};
function formatDurationTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
let timeString = '';
if (hours > 0) {
timeString += `${hours} ${hours === 1 ? 'hr' : 'hrs'} `;
}
if (minutes > 0) {
timeString += `${minutes} ${minutes === 1 ? 'min' : 'mins'} `;
}
if (remainingSeconds > 0) {
timeString += `${remainingSeconds} ${remainingSeconds === 1 ? 'second' : 'seconds'}`;
}
return timeString.trim();
}
return (
<React.Fragment>
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => {if(data.Log.length>1){setOpen(!open);}}}
>
{!open ? <AddCircleFillIcon opacity={data.Log.length>1 ?1 : 0} cursor={data.Log.length>1 ? "pointer":"default"}/> : <IndeterminateCircleFillIcon />}
</IconButton>
</TableCell>
<TableCell>{data.Name}</TableCell>
<TableCell>{data.Type}</TableCell>
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(data.TimeRun))}</TableCell>
<TableCell>{formatDurationTime(data.Duration)}</TableCell>
<TableCell>{data.ExecutionType}</TableCell>
<TableCell><div className={`badge ${ data.Result.toLowerCase() ==='success' ? 'text-bg-success' : 'text-bg-danger '} rounded-pill text-uppercase`} >{data.Result}</div></TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={7}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ margin: 1 }}>
<Table aria-label="sub-activity" className='rounded-2'>
<TableBody>
<TableRow key={data.Id}>
<TableCell colSpan="7" ><TerminalComponent data={data.Log}/></TableCell>
</TableRow>
</TableBody>
</Table>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default function Logs() {
const [data, setData]=React.useState([]);
const [rowsPerPage] = React.useState(10);
const [page, setPage] = React.useState(0);
useEffect(() => {
const fetchData = async () => {
try {
const logs = await axios.get(`/logs/getLogs`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setData(logs.data);
} catch (error) {
console.log(error);
}
};
fetchData();
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [data]);
const handleNextPageClick = () => {
setPage((prevPage) => prevPage + 1);
};
const handlePreviousPageClick = () => {
setPage((prevPage) => prevPage - 1);
};
return (
<div>
<h1 className="my-2">Logs</h1>
<TableContainer className='rounded-2'>
<Table aria-label="collapsible table" >
<TableHead>
<TableRow>
<TableCell/>
<TableCell>Name</TableCell>
<TableCell>Type</TableCell>
<TableCell>Date Created</TableCell>
<TableCell>Duration</TableCell>
<TableCell>Execution Type</TableCell>
<TableCell>Result</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data && data.sort((a, b) =>new Date(b.TimeRun) - new Date(a.TimeRun)).slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((log,index) => (
<Row key={index} data={log} />
))}
{data.length===0 ? <tr><td colSpan="7" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}} className='py-2'>No Logs Found</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}>
First
</Button>
<Button className="page-btn" onClick={handlePreviousPageClick} disabled={page === 0}>
Previous
</Button>
<div className="page-number d-flex align-items-center justify-content-center">{`${page *rowsPerPage + 1}-${Math.min((page * rowsPerPage+ 1 ) + (rowsPerPage - 1),data.length)} of ${data.length}`}</div>
<Button className="page-btn" onClick={handleNextPageClick} disabled={page >= Math.ceil(data.length / rowsPerPage) - 1}>
Next
</Button>
<Button className="page-btn" onClick={()=>setPage(Math.ceil(data.length / rowsPerPage) - 1)} disabled={page >= Math.ceil(data.length / rowsPerPage) - 1}>
Last
</Button>
</ButtonGroup>
</div>
</div>
);
}

View File

@@ -7,6 +7,8 @@ import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Button from 'react-bootstrap/Button';
import Alert from 'react-bootstrap/Alert';
import ToggleButton from 'react-bootstrap/ToggleButton';
import ToggleButtonGroup from 'react-bootstrap/ToggleButtonGroup';
@@ -21,6 +23,17 @@ export default function SettingsConfig() {
const [loadSate, setloadSate] = useState("Loading");
const [submissionMessage, setsubmissionMessage] = useState("");
const token = localStorage.getItem('token');
const [twelve_hr, set12hr] = useState(localStorage.getItem('12hr') === 'true');
const storage_12hr = localStorage.getItem('12hr');
if(storage_12hr===null)
{
localStorage.setItem('12hr',false);
set12hr(false);
}else if(twelve_hr===null){
set12hr(Boolean(storage_12hr));
}
useEffect(() => {
Config()
@@ -51,7 +64,7 @@ export default function SettingsConfig() {
},
})
.catch((error) => {
let errorMessage= `Error : ${error}`;
// let errorMessage= `Error : ${error}`;
});
let data=result.data;
@@ -102,28 +115,34 @@ export default function SettingsConfig() {
return <div className="submit critical">{submissionMessage}</div>;
}
function toggle12Hr(is_12_hr){
set12hr(is_12_hr);
localStorage.setItem('12hr',is_12_hr);
};
return (
<div>
<h1>General Settings</h1>
<h1>Settings</h1>
<Form onSubmit={handleFormSubmit} className="settings-form">
<Form.Group as={Row} className="mb-3" >
<Form.Label column className="fs-4">
<Form.Label column className="">
Jellyfin Url
</Form.Label>
<Col sm="10">
<Form.Control id="JF_HOST" name="JF_HOST" value={formValues.JF_HOST || ""} onChange={handleFormChange} placeholder="http://127.0.0.1:8096 or http://example.jellyfin.server" />
<Form.Control id="JF_HOST" name="JF_HOST" value={formValues.JF_HOST || ""} onChange={handleFormChange} placeholder="http://127.0.0.1:8096 or http://example.jellyfin.server" />
</Col>
</Form.Group>
<Form.Group as={Row} className="mb-3">
<Form.Label column className="fs-4">
<Form.Label column className="">
API Key
</Form.Label>
<Col sm="10">
<Form.Control id="JF_API_KEY" name="JF_API_KEY" value={formValues.JF_API_KEY || ""} onChange={handleFormChange} type={showKey ? "text" : "password"} />
<Form.Control id="JF_API_KEY" name="JF_API_KEY" value={formValues.JF_API_KEY || ""} onChange={handleFormChange} type={showKey ? "text" : "password"} />
</Col>
</Form.Group>
{isSubmitted !== "" ? (
@@ -139,14 +158,31 @@ export default function SettingsConfig() {
) : (
<></>
)}
<div className="d-flex flex-column flex-sm-row justify-content-end align-items-sm-center">
<div className="d-flex flex-column flex-md-row justify-content-end align-items-md-center">
<ButtonGroup >
<Button variant="outline-success" type="submit"> Save </Button>
<Button variant="outline-secondary" type="button" onClick={() => setKeyState(!showKey)}>Show Key</Button>
</ButtonGroup>
</div>
</Form>
<Form className="settings-form">
<Form.Group as={Row} className="mb-3">
<Form.Label column className="">Hour Format</Form.Label>
<Col >
<ToggleButtonGroup type="checkbox" className="d-flex" >
<ToggleButton variant="outline-primary" active={twelve_hr} onClick={()=> {toggle12Hr(true);}}>12 Hours</ToggleButton>
<ToggleButton variant="outline-primary" active={!twelve_hr} onClick={()=>{toggle12Hr(false);}}>24 Hours</ToggleButton>
</ToggleButtonGroup>
</Col>
</Form.Group>
</Form>
</div>
);

View File

@@ -45,12 +45,12 @@ function ItemStatComponent(props) {
<>
{!loaded && (
<div className="position-absolute w-100 h-100">
<Blurhash hash={props.data[0].PrimaryImageHash} width="100%" height="100%" />
<Blurhash hash={props.data[0].PrimaryImageHash} height={'100%'} className="rounded-3 overflow-hidden"/>
</div>
)}
<Card.Img
className="stat-card-image"
src={"Proxy/Items/Images/Primary?id=" + props.data[0].Id + "& fillWidth=400&quality=90"}
src={"Proxy/Items/Images/Primary?id=" + props.data[0].Id + "&fillWidth=400&quality=90"}
style={{ display: loaded ? 'block' : 'none' }}
onLoad={handleImageLoad}
onError={() => setLoaded(false)}

View File

@@ -10,6 +10,7 @@ function MostActiveUsers(props) {
const [data, setData] = useState();
const [days, setDays] = useState(30);
const [config, setConfig] = useState(null);
const [loaded, setLoaded]= useState(true);
useEffect(() => {
@@ -68,9 +69,19 @@ function MostActiveUsers(props) {
return <></>;
}
const UserImage = () => {
return (
<img src={`Proxy/Users/Images/Primary?id=${data[0].UserId}&fillWidth=100&quality=50`}
width="100%"
style={{borderRadius:'50%'}}
alt=""
onError={()=>setLoaded(false)}
/>
);
};
return (
<ItemStatComponent icon={<AccountCircleFillIcon color="white" size={"100%"}/>} data={data} heading={"MOST ACTIVE USERS"} units={"Plays"}/>
<ItemStatComponent icon={loaded ? <UserImage/> : <AccountCircleFillIcon size="100%" />} data={data} heading={"MOST ACTIVE USERS"} units={"Plays"}/>
);
}

20
src/pages/css/about.css Normal file
View File

@@ -0,0 +1,20 @@
@import './variables.module.css';
.about
{
background-color: var(--second-background-color) !important;
border-color: transparent !important;
color: white !important;
}
.about a
{
text-decoration: none;
}
.about a:hover
{
text-decoration: underline;
}

View File

@@ -10,7 +10,7 @@
td,th, td>button
{
color: white !important;
background-color: var(--secondary-background-color);
background-color: var(--tertiary-background-color);
}
@@ -89,4 +89,14 @@ font-size: 1em;
{
background-color: var(--primary-color) !important;
border-color: var(--primary-color)!important;
}
.MuiTableCell-head > .Mui-active, .MuiTableSortLabel-icon
{
color: var(--secondary-color) !important;
}
.MuiTableCell-head :hover
{
color: var(--secondary-color) !important;
}

View File

@@ -1,4 +1,5 @@
.Home
{
color: white;
margin-bottom: 20px;
}

View File

@@ -3,9 +3,12 @@
{
color:white;
background-color: var(--secondary-background-color);
padding: 20px;
margin: 20px 0;
border-radius: 8px;
}
.item-banner-image
{
margin-right: 20px;
}
.item-name

View File

@@ -2,13 +2,14 @@
.last-played-container {
display: flex;
overflow-x: auto;
overflow-x: scroll;
background-color: var(--secondary-background-color);
padding: 20px;
border-radius: 8px;
color: white;
margin-bottom: 20px;
min-height: 300px;
}
.last-played-container::-webkit-scrollbar {

View File

@@ -2,7 +2,9 @@
{
color: white;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-gap: 20px;
}

View File

@@ -2,7 +2,7 @@
.lib-card{
color: white;
max-width: 400px;
/* max-width: 400px; */
}
@@ -20,6 +20,7 @@
.library-card-image
{
max-height: 170px;
overflow: hidden;
}
@@ -30,7 +31,7 @@
background-repeat: no-repeat;
background-size: cover;
transition: all 0.2s ease-in-out;
max-height: 170px;
}

View File

@@ -33,7 +33,7 @@
background-color: #88888883; /* set thumb color */
}
.form-control
.library-items > div> .form-control
{
color: white !important;
background-color: var(--secondary-background-color) !important;
@@ -41,7 +41,7 @@
}
.form-control:focus
.library-items > div> .form-control:focus
{
box-shadow: none !important;
border-color: var(--primary-color) !important;

View File

@@ -4,8 +4,9 @@
grid-template-columns: repeat(auto-fit, minmax(320px, 520px));
grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/
background-color: var(--secondary-background-color);
border-radius: 8px;
/* margin-right: 20px; */
padding: 20px;
}
.library-stat-card
@@ -84,12 +85,8 @@
.library-banner-image
{
height: 180px;
width: 120px;
width: 120px;
}

View File

@@ -1,3 +1,4 @@
@import './variables.module.css';
.loading {
margin: 0px;
@@ -6,8 +7,7 @@
display: flex;
justify-content: center;
align-items: center;
/* z-index: 9999; */
background-color: #1e1c22;
background-color: var(--background-color);
transition: opacity 800ms ease-in;
opacity: 1;
}

View File

@@ -1,37 +1,85 @@
@import './variables.module.css';
.navbar {
background-color: var(--primary-color);
background-color: var(--secondary-background-color);
border-right: 1px solid #414141 !important;
}
@media (min-width: 768px) {
.navbar {
min-height: 100vh;
border-bottom: 1px solid #414141 !important;
}
}
.navbar .navbar-brand{
margin-top: 20px;
font-size: 32px;
font-weight: 500;
}
.navbar .navbar-nav{
width: 100%;
margin-top: 20px;
}
.logout
{
color: var(--secondary-color) !important;
}
.navbar-toggler > .collapsed
{
right: 0;
}
/* .navbar-toggler-icon
{
width: 100% !important;
} */
.navitem {
display: flex;
align-items: center;
height: 100%;
color: white;
font-size: 16px;
font-size: 18px !important;
text-decoration: none;
padding: 0 20px;
margin-right: 10px;
background-color: var(--background-color);
transition: all 0.4s ease-in-out;
border-radius: 8px;
margin-bottom: 10px;
width: 90%;
}
@media (min-width: 768px) {
.navitem {
border-radius: 0 8px 8px 0;
}
}
.navitem:hover {
background-color: var(--primary-color);
}
.active
{
background-color: var(--primary-color);
transition: background-color 0.2s ease-in-out;
}
.nav-link
{
display: flex !important;
}
.nav-text {
margin-left: 10px;
}
.active
{
/* background-color: #308df046 !important; */
background-color: rgba(0,0,0,0.6);
transition: background-color 0.2s ease-in-out;
}
.navitem:hover {
/* background-color: #326aa541; */
background-color: rgba(0,0,0,0.4);
}

View File

@@ -4,6 +4,9 @@
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 520px));
grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/
background-color: var(--secondary-background-color);
border-radius: 8px;
padding: 20px;
}
.session-card {

View File

@@ -31,4 +31,4 @@ td{
.upload-file:focus
{
box-shadow: none !important;
}
}

View File

@@ -14,7 +14,7 @@
.tasks {
color: white;
margin-inline: 10px;
/* margin-inline: 10px; */
}
@@ -56,4 +56,19 @@
margin-bottom: 5px;
}
.settings-form > div> div> .form-control
{
color: white !important;
background-color: var(--background-color) !important;
border-color: var(--background-color) !important;
}
.settings-form > div> div> .form-control:focus
{
box-shadow: none !important;
border-color: var(--primary-color) !important;
}

View File

@@ -0,0 +1,46 @@
@import '../variables.module.css';
.version
{
background-color: var(--background-color) !important;
color: white !important;
position: fixed !important;
bottom: 0;
max-width: 200px;
text-align: center;
width: 100%;
}
.version a
{
text-decoration: none;
}
.version a:hover
{
text-decoration: underline;
}
.nav-pills > .nav-item , .nav-pills > .nav-item > .nav-link
{
color: white !important;
}
.nav-pills > .nav-item .active
{
background-color: var(--primary-color) !important;
color: white !important;
}
.nav-pills > .nav-item :hover
{
background-color: var(--primary-color) !important;
}
.nav-pills > .nav-item .active> .nav-link
{
color: white;
}

View File

@@ -51,8 +51,8 @@ h2{
pointer-events: none;
transition: .2s;
}
input:focus ~ label,
input:valid ~ label{
.form-box> form> .inputbox> input:focus ~ label,
.form-box> form> .inputbox> input:valid ~ label{
top: -15px;
}
.inputbox input {

View File

@@ -5,6 +5,9 @@
grid-template-columns: repeat(auto-fit, minmax(320px, 520px));
grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/
margin-top: 8px;
background-color: var(--secondary-background-color);
border-radius: 8px;
padding: 20px;
}
.stat-card{
border: 0 !important;
@@ -21,6 +24,8 @@
.stat-card-image {
width: 120px !important;
height: 180px;
@@ -29,7 +34,12 @@
.stat-card-icon
{
width: 120px !important;
height: 180px;
position: relative;
top: 50%;
left: 65%;
transform: translate(-50%, -50%);
}

View File

@@ -1,4 +1,5 @@
@import '../variables.module.css';
.user-detail-container
{
color:white;

View File

@@ -2,5 +2,6 @@
--primary-color: #5a2da5;
--secondary-color: #00A4DC;
--background-color: #1e1c22;
--secondary-background-color: rgba(100, 100, 100,0.2);
--secondary-background-color: #2c2a2f;
--tertiary-background-color: #2f2e31;
}

View File

@@ -10,6 +10,7 @@
.console-message {
margin-bottom: 10px;
font-size: 1.1rem;
}
.console-text {

View File

@@ -0,0 +1,142 @@
/*sourced from https://drive.google.com/uc?export=view&id=1yTLwNiCZhIdCWolQldwq4spHQkgZDqkG */
/* Small devices (landscape phones, 576px and up)*/
@media (min-width: 576px) {
.w-sm-100 {
width: 100% !important;
}
.w-sm-75 {
width: 75% !important;
}
.w-sm-50 {
width: 50% !important;
}
.w-sm-25 {
width: 25% !important;
}
.h-sm-100 {
height: 100% !important;
}
.h-sm-75 {
height: 75% !important;
}
.h-sm-50 {
height: 50% !important;
}
.h-sm-25 {
height: 25% !important;
}
}
/* Medium devices (tablets, 768px and up)*/
@media (min-width: 768px) {
.w-md-100 {
width: 100% !important;
}
.w-md-75 {
width: 75% !important;
}
.w-md-50 {
width: 50% !important;
}
.w-md-25 {
width: 25% !important;
}
.h-md-100 {
height: 100% !important;
}
.h-md-75 {
height: 75% !important;
}
.h-md-50 {
height: 50% !important;
}
.h-md-25 {
height: 25% !important;
}
}
/* Large devices (desktops, 992px and up)*/
@media (min-width: 992px) {
.w-lg-100 {
width: 100% !important;
}
.w-lg-75 {
width: 75% !important;
}
.w-lg-50 {
width: 50% !important;
}
.w-lg-25 {
width: 25% !important;
}
.h-lg-100 {
height: 100% !important;
}
.h-lg-75 {
height: 75% !important;
}
.h-lg-50 {
height: 50% !important;
}
.h-lg-25 {
height: 25% !important;
}
}
/* Extra large devices (large desktops, 1200px and up)*/
@media (min-width: 1200px) {
.w-xl-100 {
width: 100% !important;
}
.w-xl-75 {
width: 75% !important;
}
.w-xl-50 {
width: 50% !important;
}
.w-xl-25 {
width: 25% !important;
}
.h-xl-100 {
height: 100% !important;
}
.h-xl-75 {
height: 75% !important;
}
.h-xl-50 {
height: 50% !important;
}
.h-xl-25 {
height: 25% !important;
}
}

View File

@@ -5,13 +5,14 @@ import './css/home.css'
import Sessions from './components/sessions/sessions'
import HomeStatisticCards from './components/HomeStatisticCards'
import LibraryOverView from './components/libraryOverview'
import RecentlyAdded from './components/library/recently-added'
export default function Home() {
return (
<div>
<div className='Home'>
<Sessions />
<RecentlyAdded/>
<HomeStatisticCards/>
<LibraryOverView/>

View File

@@ -7,7 +7,7 @@ import "./css/library/libraries.css";
import Loading from "./components/general/loading";
import LibraryCard from "./components/library/library-card";
import Row from "react-bootstrap/Row";
import Container from "react-bootstrap/Container";
@@ -82,7 +82,7 @@ function Libraries() {
<div className="libraries">
<h1 className="py-4">Libraries</h1>
<Row xs={1} md={2} lg={4} className="g-4">
<div xs={1} md={2} lg={4} className="g-0 libraries-container">
{data &&
data.map((item) => (
@@ -90,7 +90,7 @@ function Libraries() {
))}
</Row>
</div>
</div>
);

View File

@@ -2,11 +2,12 @@ import React from "react";
import {Tabs, Tab } from 'react-bootstrap';
import SettingsConfig from "./components/settings/settingsConfig";
import LibrarySync from "./components/settings/librarySync";
import Tasks from "./components/settings/Tasks";
import BackupFiles from "./components/settings/backupfiles";
import TerminalComponent from "./components/settings/TerminalComponent";
import Logs from "./components/settings/logs";
// import TerminalComponent from "./components/settings/TerminalComponent";
@@ -18,19 +19,28 @@ export default function Settings() {
return (
<div className="settings my-2">
<Tabs defaultActiveKey="tabGeneral" variant='pills'>
<Tab eventKey="tabGeneral" className='bg-transparent my-2' title='General Settings' style={{minHeight:'500px'}}>
<SettingsConfig/>
</Tab>
<Tab eventKey="tabTasks" className='bg-transparent my-2' title='Tasks' style={{minHeight:'500px'}}>
<LibrarySync/>
<Tab eventKey="tabGeneral" className='bg-transparent my-2' title='Settings' style={{minHeight:'500px'}}>
<SettingsConfig/>
<Tasks/>
</Tab>
<Tab eventKey="tabBackup" className='bg-transparent my-2' title='Backup' style={{minHeight:'500px'}}>
<BackupFiles/>
<BackupFiles/>
</Tab>
<Tab eventKey="tabLogs" className='bg-transparent my-2' title='Logs' style={{minHeight:'500px'}}>
<Logs/>
</Tab>
</Tabs>
<TerminalComponent/>
{/* <TerminalComponent/> */}
</div>
);

View File

@@ -9,7 +9,7 @@ import './css/library/libraries.css';
// import LibraryOverView from './components/libraryOverview';
// import HomeStatisticCards from './components/HomeStatisticCards';
// import Sessions from './components/sessions/sessions';
import DailyPlayStats from './components/statistics/daily-play-count';
import MostActiveUsers from './components/statCards/most_active_users';
@@ -52,7 +52,7 @@ function Testing() {
return (
<div className='Activity'>
<DailyPlayStats/>
<MostActiveUsers/>
</div>

View File

@@ -11,6 +11,10 @@ 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 TableSortLabel from '@mui/material/TableSortLabel';
import Box from '@mui/material/Box';
import { visuallyHidden } from '@mui/utils';
import "./css/users/users.css";
@@ -18,6 +22,85 @@ import Loading from "./components/general/loading";
const token = localStorage.getItem('token');
function EnhancedTableHead(props) {
const { order, orderBy, onRequestSort } =
props;
const createSortHandler = (property) => (event) => {
onRequestSort(event, property);
};
const headCells = [
{
id: 'UserName',
numeric: false,
disablePadding: true,
label: 'User',
},
{
id: 'LastWatched',
numeric: false,
disablePadding: false,
label: 'Last Watched',
},
{
id: 'LastClient',
numeric: false,
disablePadding: false,
label: 'Last Client',
},
{
id: 'TotalPlays',
numeric: false,
disablePadding: false,
label: 'Plays',
},
{
id: 'TotalWatchTime',
numeric: false,
disablePadding: false,
label: 'Watch Time',
},
{
id: 'LastSeen',
numeric: false,
disablePadding: false,
label: 'Last Seen',
},
];
return (
<TableHead>
<TableRow>
<TableCell/>
{headCells.map((headCell) => (
<TableCell
key={headCell.id}
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'}
onClick={createSortHandler(headCell.id)}
>
{headCell.label}
{orderBy === headCell.id ? (
<Box component="span" sx={visuallyHidden}>
{order === 'desc' ? 'sorted descending' : 'sorted ascending'}
</Box>
) : null}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
);
}
function Row(row) {
const { data } = row;
@@ -80,7 +163,7 @@ function Row(row) {
<TableCell><Link to={`/libraries/item/${data.NowPlayingItemId}`} className="text-decoration-none">{data.LastWatched || 'never'}</Link></TableCell>
<TableCell>{data.LastClient || 'n/a'}</TableCell>
<TableCell>{data.TotalPlays}</TableCell>
<TableCell>{formatTotalWatchTime(data.TotalWatchTime) || 0}</TableCell>
<TableCell>{formatTotalWatchTime(data.TotalWatchTime) || '0 minutes'}</TableCell>
<TableCell>{data.LastSeen ? formatLastSeenTime(data.LastSeen) : 'never'}</TableCell>
</TableRow>
@@ -95,6 +178,11 @@ function Users() {
const [page, setPage] = React.useState(0);
const [itemCount,setItemCount] = useState(10);
const [order, setOrder] = React.useState('asc');
const [orderBy, setOrderBy] = React.useState('LastSeen');
@@ -154,6 +242,101 @@ function Users() {
setPage((prevPage) => prevPage - 1);
};
function formatLastSeenTime(time) {
if(!time)
{
return ' never';
}
const units = {
days: ['Day', 'Days'],
hours: ['Hour', 'Hours'],
minutes: ['Minute', 'Minutes'],
seconds: ['Second', 'Seconds']
};
let formattedTime = '';
for (const unit in units) {
if (time[unit]) {
const unitName = units[unit][time[unit] > 1 ? 1 : 0];
formattedTime += `${time[unit]} ${unitName} `;
}
}
return `${formattedTime}ago`;
}
function descendingComparator(a, b, orderBy) {
if (orderBy==='LastSeen') {
let order_a=formatLastSeenTime(a[orderBy]);
let order_b=formatLastSeenTime(b[orderBy]);
if (order_b > order_a) {
return -1;
}
if (order_a< order_b) {
return 1;
}
return 0;
}
if (orderBy === 'TotalPlays') {
let order_a = parseInt(a[orderBy]);
let order_b = parseInt(b[orderBy]);
if (order_a < order_b) {
return -1;
}
if (order_a > order_b) {
return 1;
}
return 0;
}
if (b[orderBy] < a[orderBy]) {
return -1;
}
if (b[orderBy] > a[orderBy]) {
return 1;
}
return 0;
}
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;
}
return a[1] - b[1];
});
return stabilizedThis.map((el) => el[0]);
}
const visibleRows = stableSort(data, getComparator(order, orderBy)).slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage,
);
const handleRequestSort = (event, property) => {
const isAsc = orderBy === property && order === 'asc';
setOrder(isAsc ? 'desc' : 'asc');
setOrderBy(property);
};
@@ -174,20 +357,14 @@ function Users() {
<TableContainer className='rounded-2'>
<Table aria-label="collapsible table" >
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell>User</TableCell>
<TableCell>Last Watched</TableCell>
<TableCell>Last Client</TableCell>
<TableCell>Plays</TableCell>
<TableCell>Watch Time</TableCell>
<TableCell>Last Seen</TableCell>
</TableRow>
</TableHead>
<EnhancedTableHead
order={order}
orderBy={orderBy}
onRequestSort={handleRequestSort}
rowCount={rowsPerPage}
/>
<TableBody>
{data && data.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row) => (
{visibleRows.map((row) => (
<Row key={row.UserId} data={row} hostUrl={config.hostUrl}/>
))}
{data.length===0 ? <tr><td colSpan="5" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}}>No Users Found</td></tr> :''}

View File

@@ -43,6 +43,13 @@ module.exports = function(app) {
changeOrigin: true,
})
);
app.use(
`/logs`,
createProxyMiddleware({
target: `http://127.0.0.1:${process.env.PORT || 3003}`,
changeOrigin: true,
})
);
app.use(
`/ws`,
createProxyMiddleware({