mirror of
https://github.com/BreizhHardware/Jellystat.git
synced 2026-01-18 16:27:20 +01:00
completed basic backup and restore
Basic backup and restore, will need to to do extensive testing but it should work Reworked websocket class to allow for updating rows of data instead of sending a new row for an updated state Cleaned up more stats that used jf_all_user_activity
This commit is contained in:
@@ -27,7 +27,28 @@ function createWebSocketServer() {
|
||||
|
||||
// define a separate method that sends a message to all connected clients
|
||||
function sendMessageToClients(message) {
|
||||
messages.push(message);
|
||||
|
||||
if(message.key)
|
||||
{
|
||||
|
||||
const findMessage = messages.filter(item => item.hasOwnProperty('key')).find(item => item.key === message.key);
|
||||
if(findMessage)
|
||||
{
|
||||
messages.filter(item => item.hasOwnProperty('key')).forEach(item => {
|
||||
|
||||
if (item.key === message.key) {
|
||||
item.Message = message.Message;
|
||||
}
|
||||
});
|
||||
}else{
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
|
||||
}else{
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(messages));
|
||||
|
||||
@@ -187,7 +187,7 @@ router.get("/getHistory", async (req, res) => {
|
||||
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT * FROM jf_all_playback_activity order by "ActivityDateInserted" desc`
|
||||
`SELECT * FROM jf_playback_activity order by "ActivityDateInserted" desc`
|
||||
);
|
||||
|
||||
const groupedResults = {};
|
||||
|
||||
1
backend/backup-data/backup_2023-04-24 07-10-02.json
Normal file
1
backend/backup-data/backup_2023-04-24 07-10-02.json
Normal file
File diff suppressed because one or more lines are too long
@@ -6,9 +6,6 @@ const moment = require('moment');
|
||||
|
||||
const wss = require("./WebsocketHandler");
|
||||
|
||||
var messages=[];
|
||||
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Database connection parameters
|
||||
@@ -21,6 +18,7 @@ const postgresDatabase = process.env.POSTGRES_DATABASE || 'jfstat';
|
||||
// Tables to back up
|
||||
const tables = ['jf_libraries', 'jf_library_items', 'jf_library_seasons','jf_library_episodes','jf_users','jf_playback_activity','jf_playback_reporting_plugin_data','jf_item_info'];
|
||||
|
||||
|
||||
// Backup function
|
||||
async function backup() {
|
||||
const pool = new Pool({
|
||||
@@ -32,10 +30,15 @@ async function backup() {
|
||||
});
|
||||
|
||||
// Get data from each table and append it to the backup file
|
||||
|
||||
|
||||
try{
|
||||
|
||||
let now = moment();
|
||||
const backupPath = `./backup-data/backup_${now.format('yyyy-MM-DD HH-mm-ss')}.json`;
|
||||
const stream = fs.createWriteStream(backupPath, { flags: 'a' });
|
||||
const backup_data=[];
|
||||
|
||||
wss.clearMessages();
|
||||
wss.sendMessageToClients({ color: "yellow", Message: "Begin Backup "+backupPath });
|
||||
for (let table of tables) {
|
||||
@@ -43,104 +46,110 @@ async function backup() {
|
||||
|
||||
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}`});
|
||||
wss.sendMessageToClients({color: "dodgerblue",Message: `Saving ${rows.length} rows for table ${table}`});
|
||||
|
||||
backup_data.push({[table]:rows});
|
||||
// stream.write(JSON.stringify(backup_data));
|
||||
backup_data.push({[table]:rows});
|
||||
|
||||
}
|
||||
|
||||
wss.sendMessageToClients({ color: "lawngreen", Message: "Backup Complete" });
|
||||
stream.write(JSON.stringify(backup_data));
|
||||
stream.end();
|
||||
|
||||
await stream.write(JSON.stringify(backup_data));
|
||||
stream.end();
|
||||
wss.sendMessageToClients({ color: "lawngreen", Message: "Backup Complete" });
|
||||
|
||||
}catch(error)
|
||||
{
|
||||
console.log(error);
|
||||
wss.sendMessageToClients({ color: "red", Message: "Backup Failed: "+error });
|
||||
}
|
||||
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
// Restore function
|
||||
async function restore() {
|
||||
// const pool = new Pool({
|
||||
// user: 'postgres',
|
||||
// password: 'mypassword',
|
||||
// host: postgresIp,
|
||||
// port: 25432,
|
||||
// database: postgresDatabase
|
||||
// });
|
||||
|
||||
let user='postgres';
|
||||
let password='mypassword';
|
||||
let host=postgresIp;
|
||||
let port=25432;
|
||||
let database= postgresDatabase;
|
||||
|
||||
const client = new Pool({
|
||||
host,
|
||||
port,
|
||||
database,
|
||||
user,
|
||||
password
|
||||
});
|
||||
const backupPath = './backup-data/backup.json';
|
||||
|
||||
let jsonData;
|
||||
await fs.readFile(backupPath, 'utf8', async (err, data) => {
|
||||
function readFile(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(path, 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
jsonData = await JSON.parse(data);
|
||||
const json = JSON.parse(data);
|
||||
resolve(json);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log(jsonData);
|
||||
async function restore(file) {
|
||||
wss.clearMessages();
|
||||
wss.sendMessageToClients({ color: "yellow", Message: "Restoring from Backup: "+file });
|
||||
const pool = new Pool({
|
||||
user: postgresUser,
|
||||
password: postgresPassword,
|
||||
host: postgresIp,
|
||||
port: postgresPort,
|
||||
database: postgresDatabase
|
||||
});
|
||||
|
||||
const backupPath = file;
|
||||
|
||||
let jsonData;
|
||||
|
||||
try {
|
||||
// Use await to wait for the Promise to resolve
|
||||
jsonData = await readFile(backupPath);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// console.log(jsonData);
|
||||
if(!jsonData)
|
||||
{
|
||||
console.log('No Data');
|
||||
return;
|
||||
}
|
||||
|
||||
jsonData.forEach((library) => {
|
||||
for(let table of jsonData)
|
||||
{
|
||||
const data = Object.values(table)[0];
|
||||
const tableName=Object.keys(table)[0];
|
||||
|
||||
console.log(library);
|
||||
});
|
||||
for(let index in data)
|
||||
{
|
||||
|
||||
// await client.connect();
|
||||
wss.sendMessageToClients({ color: "dodgerblue",key:tableName ,Message: `Restoring ${tableName} ${(((index)/(data.length-1))*100).toFixed(2)}%`});
|
||||
|
||||
|
||||
const keysWithQuotes = Object.keys(data[index]).map(key => `"${key}"`);
|
||||
const keyString = keysWithQuotes.join(", ");
|
||||
|
||||
const valuesWithQuotes = Object.values(data[index]).map(col => {
|
||||
if (col === null) {
|
||||
return 'NULL';
|
||||
} else if (typeof col === 'string') {
|
||||
return `'${col.replace(/'/g, "''")}'`;
|
||||
}else if (typeof col === 'object') {
|
||||
return `'${JSON.stringify(col).replace(/'/g, "''")}'`;
|
||||
} else {
|
||||
return `'${col}'`;
|
||||
}
|
||||
});
|
||||
|
||||
const valueString = valuesWithQuotes.join(", ");
|
||||
|
||||
|
||||
const query=`INSERT INTO ${tableName} (${keyString}) VALUES(${valueString}) ON CONFLICT DO NOTHING`;
|
||||
const { rows } = await pool.query( query );
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
await pool.end();
|
||||
|
||||
// let tableStarted = false;
|
||||
// let tableColumns = '';
|
||||
// let tableValues = '';
|
||||
// let tableName = '';
|
||||
|
||||
// for await (const line of rl) {
|
||||
// if (!tableStarted && line.startsWith('COPY ')) {
|
||||
// tableName = line.match(/COPY (.*) \(/)[1];
|
||||
// tableStarted = true;
|
||||
// tableColumns = '';
|
||||
// tableValues = '';
|
||||
// } else if (tableStarted && line.startsWith('\.')) {
|
||||
// const insertStatement = `INSERT INTO ${tableName} (${tableColumns}) VALUES ${tableValues};`;
|
||||
// await client.query(insertStatement);
|
||||
// tableStarted = false;
|
||||
// } else if (tableStarted && tableColumns === '') {
|
||||
// tableColumns = line.replace(/\(/g, '').replace(/\)/g, '').replace(/"/g, '').trim();
|
||||
// tableValues = '';
|
||||
// } else if (tableStarted) {
|
||||
// const values = line.replace(/\(/g, '').replace(/\)/g, '').split('\t').map(value => {
|
||||
// if (value === '') {
|
||||
// return null;
|
||||
// }
|
||||
// if (!isNaN(parseFloat(value))) {
|
||||
// return parseFloat(value);
|
||||
// }
|
||||
// return value.replace(/'/g, "''");
|
||||
// });
|
||||
// const rowValues = `(${values.join(',')})`;
|
||||
// tableValues = `${tableValues}${tableValues === '' ? '' : ','}${rowValues}`;
|
||||
// }
|
||||
// }
|
||||
|
||||
// await client.end();
|
||||
}
|
||||
|
||||
// Route handler for backup endpoint
|
||||
@@ -154,13 +163,16 @@ router.get('/backup', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/restore', async (req, res) => {
|
||||
router.get('/restore/:filename', async (req, res) => {
|
||||
try {
|
||||
await restore();
|
||||
res.send('Backup completed successfully');
|
||||
const filePath = path.join(__dirname, backupfolder, req.params.filename);
|
||||
await restore(filePath);
|
||||
wss.sendMessageToClients({ color: "lawngreen", Message: `Restoring Complete` });
|
||||
res.send('Restore completed successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Backup failed');
|
||||
wss.sendMessageToClients({ color: "red", Message: error });
|
||||
res.status(500).send('Restore failed');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -174,7 +186,8 @@ router.get('/restore', async (req, res) => {
|
||||
if (err) {
|
||||
res.status(500).send('Unable to read directory');
|
||||
} else {
|
||||
const fileData = files.map(file => {
|
||||
const fileData = files.filter(file => file.endsWith('.json'))
|
||||
.map(file => {
|
||||
const filePath = path.join(directoryPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
return {
|
||||
|
||||
@@ -170,7 +170,7 @@ router.get("/getPlaybackActivity", async (req, res) => {
|
||||
|
||||
router.get("/getAllUserActivity", async (req, res) => {
|
||||
try {
|
||||
const { rows } = await db.query("SELECT * FROM jf_all_user_activity");
|
||||
const { rows } = await db.query("SELECT * FROM jf_user_activity");
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.send(error);
|
||||
|
||||
@@ -66,7 +66,21 @@ function ActivityTable(props) {
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function formatTotalWatchTime(seconds) {
|
||||
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`;
|
||||
}
|
||||
|
||||
return formattedTime ;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +92,7 @@ function ActivityTable(props) {
|
||||
<div onClick={() => handleSort("UserName")}>User</div>
|
||||
<div onClick={() => handleSort("NowPlayingItemName")}>Title </div>
|
||||
<div onClick={() => handleSort("ActivityDateInserted")}>Date</div>
|
||||
<div onClick={() => handleSort("PlaybackDuration")}>Playback Duration</div>
|
||||
<div onClick={() => handleSort("results")}>Total Plays</div>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +103,7 @@ function ActivityTable(props) {
|
||||
<div><Link to={`/users/${item.UserId}`}>{item.UserName}</Link></div>
|
||||
<div><Link to={`/item/${item.EpisodeId || item.NowPlayingItemId}`}>{!item.SeriesName ? item.NowPlayingItemName : item.SeriesName+' - '+ item.NowPlayingItemName}</Link></div>
|
||||
<div>{Intl.DateTimeFormat('en-UK', options).format(new Date(item.ActivityDateInserted))}</div>
|
||||
<div>{formatTotalWatchTime(item.PlaybackDuration) || '0 sec'}</div>
|
||||
<div>{item.results.length+1}</div>
|
||||
</div>
|
||||
<div className={`sub-table ${item.isCollapsed ? 'collapsed' : ''}`}>
|
||||
@@ -98,6 +114,7 @@ function ActivityTable(props) {
|
||||
<div><Link to={`/item/${sub_item.EpisodeId || sub_item.NowPlayingItemId}`}>{!sub_item.SeriesName ? sub_item.NowPlayingItemName : sub_item.SeriesName+' - '+ sub_item.NowPlayingItemName}</Link></div>
|
||||
<div>{Intl.DateTimeFormat('en-UK', options).format(new Date(sub_item.ActivityDateInserted))}</div>
|
||||
<div></div>
|
||||
<div>1</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -21,16 +21,6 @@ const TerminalComponent = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// function to handle scrolling to the last message
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// scroll to the last message whenever messages change
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className='my-4'>
|
||||
<div className="console-container">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState,useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import Button from "react-bootstrap/Button";
|
||||
import { DropdownButton, Dropdown, Button } from 'react-bootstrap';
|
||||
import Alert from "react-bootstrap/Alert";
|
||||
|
||||
|
||||
@@ -59,6 +59,25 @@ export default function BackupFiles() {
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreBackup(filename) {
|
||||
const url=`/data/restore/${filename}`;
|
||||
axios
|
||||
.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
setshowAlert({visible:true,title:'Success',type:'success',message:response.data});
|
||||
})
|
||||
.catch((error) => {
|
||||
setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data});
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
async function deleteBackup(filename) {
|
||||
const url=`/data/files/${filename}`;
|
||||
axios
|
||||
@@ -138,7 +157,6 @@ export default function BackupFiles() {
|
||||
<th>Date Created</th>
|
||||
<th>Size</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -148,8 +166,15 @@ export default function BackupFiles() {
|
||||
<td>{file.name}</td>
|
||||
<td>{Intl.DateTimeFormat('en-UK', options).format(new Date(file.datecreated))}</td>
|
||||
<td>{formatFileSize(file.size)}</td>
|
||||
<td ><Button type="button" onClick={()=>downloadBackup(file.name)} >Download</Button></td>
|
||||
<td ><Button type="button" className="btn-danger" onClick={()=>deleteBackup(file.name)} >Delete</Button></td>
|
||||
<td>
|
||||
<DropdownButton title="Actions" variant="outline-primary">
|
||||
|
||||
<Dropdown.Item as="button" variant="primary" onClick={()=>downloadBackup(file.name)}>Download</Dropdown.Item>
|
||||
<Dropdown.Item as="button" variant="warning" onClick={()=>restoreBackup(file.name)}>Restore</Dropdown.Item>
|
||||
<Dropdown.Divider ></Dropdown.Divider>
|
||||
<Dropdown.Item as="button" variant="danger" onClick={()=>deleteBackup(file.name)}>Delete</Dropdown.Item>
|
||||
</DropdownButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{files.length===0 ? <tr><td colSpan="5" style={{ textAlign: "center", fontStyle: "italic" ,color:"gray"}}>No Backups Found</td></tr> :''}
|
||||
|
||||
@@ -70,7 +70,7 @@ div a
|
||||
.collapsed {
|
||||
transition: all 0.3s ease;
|
||||
opacity: 100;
|
||||
max-height: 500px;
|
||||
max-height: min-content;
|
||||
}
|
||||
|
||||
.sub-row{
|
||||
|
||||
Reference in New Issue
Block a user