Rework project and also include checking monitoring

This commit is contained in:
Michael Reber 2025-05-09 22:00:00 +02:00
parent d51b815b92
commit 9fbbf821f0
3 changed files with 303 additions and 415 deletions

View File

@ -1,7 +1,23 @@
import { Component, Fragment } from 'react'
import ReactTooltip from 'react-tooltip'
import { Container, HeaderContainer, InfosContainer, InfosWrapper, ItemsContainer, ActiveContainer, HistoryContainer, HistoryItem, HistoryItemsWrapper, LogoContainer, StatusContainer, StatusBulb, InfosRow } from './styled'
import {
Container,
HeaderContainer,
InfosContainer,
InfosWrapper,
ItemsContainer,
ActiveContainer,
Spinner,
HistoryContainer,
HistoryItem,
HistoryItemsWrapper,
LogoContainer,
StatusContainer,
StatusBulb,
InfosRow,
ActiveChecking
} from './styled'
import API from './utils/API'
import secondsToTimeString from './utils/timestring'
@ -10,105 +26,41 @@ import bytesToString from './utils/bytestring'
import Settings from './components/settings'
import Job from './components/job'
import Navigation from './components/navigation'
//import FileBrowserMenu from './components/fileBrowserMenu'
class App extends Component {
constructor() {
super()
this.state = {
stats: {
// bytes: 0,
//checking: [
// "a very very very long absurdly unneccesarily long name this is if you cant tell",
// "a very very very long absurdly unneccesarily long name this is if you cant tell 2"
//]
// checks: 0,
// deletedDirs: 0,
// deletes: 0,
// elapsedTime: 0,
// errors: 0,
// eta: null,
// fatalError: false,
// renames: 0,
// retryError: false,
// speed: 0,
// totalBytes: 0,
// totalChecks: 0,
// totalTransfers: 0,
// transferTime: 0,
// transferring: [
// {
// bytes: 590673
// dstFs: "/mnt/backup01/data_replica"
// eta: 0
// group: "job/17553"
// name: "a very very very long absurdly unneccesarily long name this is if you cant tell"
// percentage: 100
// size: 590673
// speed: 806565.7017727684
// speedAvg: 574257.5080241694
// srcFs: "/mnt/main_storage/data"
// }
// ],
// transfers: 0,
elapsedTime: 0,
transfers: 0,
bytes: 0,
errors: 0,
lastError: '',
transferring: [],
checking: []
},
//remotes: [{
// name: "gdrive",
// type: "drive",
// bytes: 84265292526
//}],
remotes: [],
mounts: [],
transferred: [],
version: {
// "arch": "amd64",
// "decomposed": [
// 1,
// 56,
// 2
// ],
// "goTags": "none",
// "goVersion": "go1.16.8",
// "isBeta": false,
// "isGit": false,
// "linking": "static",
// "os": "linux",
// "version": "v1.56.2"
},
endPointAvailable: true,
renderBrowser: false
transferred: [], // history
version: {},
endPointAvailable: true
}
this.infoInterval = null
this.apiInterval = null
}
this.infoInterval = undefined
this.timeInterval = undefined
this.apiInterval = undefined
}
componentDidMount = () => {
// fetch initial info
componentDidMount() {
this.fetchRemotes()
this.fetchMounts()
this.fetchVersionInfo()
this.fetchInfos()
// get server stats every 1 seconds
if (this.infoInterval === undefined) this.infoInterval = setInterval(this.fetchInfos, 1000)
// make the view look more responsive by counting every second
// if (this.timeInterval === undefined) this.timeInterval = setInterval(() => {
// let { stats } = this.state
// stats.elapsedTime++
// this.setState({ stats })
// }, 1000)
// check api status every second
if (this.apiInterval === undefined) this.apiInterval = setInterval(this.checkApiEndpoint, 1000)
this.infoInterval = setInterval(this.fetchInfos, 2000)
this.apiInterval = setInterval(this.checkApiEndpoint, 5000)
}
// clear the intervals
componentWillUnmount = () => {
componentWillUnmount() {
clearInterval(this.infoInterval)
clearInterval(this.timeInterval)
clearInterval(this.apiInterval)
}
@ -120,21 +72,7 @@ class App extends Component {
checkApiEndpoint = () => {
const status = API.getEndpointStatus()
if (status !== this.state.endPointAvailable) {
this.setState({ endPointAvailable: !this.state.endPointAvailable })
const link = document.querySelectorAll("link[rel~='icon']");
link.forEach(v => {
let segments = v.href.split(".")
if (status) {
segments[0] = segments[0].substring(0, segments[0].length - 3)
} else {
segments[0] += "-gs"
}
v.href = segments.join(".")
})
// get info after api recovers
this.setState({ endPointAvailable: status })
if (status) {
this.fetchRemotes()
this.fetchMounts()
@ -143,239 +81,187 @@ class App extends Component {
}
}
/**
* get the configured remotes
*/
fetchRemotes = () => {
return API.request({
url: "/config/dump",
"_group": "ui"
})
.then(response => {
if (typeof response.data !== "object") throw new Error("invalid response")
let remotes = []
return Promise.all(Object.keys(response.data).map(v => {
return new Promise((resolve, reject) => {
return API.request({
url: "/operations/about",
data: {
fs: v + ":"
}
})
API.request({ url: '/config/dump', _group: 'ui' })
.then(res => {
if (typeof res.data !== 'object') throw new Error()
const remotes = []
return Promise.all(
Object.keys(res.data).map(name =>
API.request({ url: '/operations/about', data: { fs: name + ':' } })
.then(({ data }) => {
if (typeof data !== "object" || isNaN(data.used)) return reject()
remotes.push({
name: v,
type: response.data[v].type,
bytes: data.used
})
return resolve()
})
.catch(reject)
})
if (typeof data !== 'object' || isNaN(data.used)) throw new Error()
remotes.push({ name, type: res.data[name].type, bytes: data.used })
})
)
.then(() => {
this.setState({ remotes })
})
.catch(err => console.error(err))
).then(() => this.setState({ remotes }))
})
.catch(() => {})
}
/**
* get the mounted volumes
*/
fetchMounts = () => {
return API.request({
url: "/mount/listmounts"
})
.then(response => {
if (typeof response.data.mountPoints !== "object") throw new Error("invalid response")
this.setState({ mounts: response.data.mountPoints })
API.request({ url: '/mount/listmounts' })
.then(res => {
if (!Array.isArray(res.data.mountPoints)) throw new Error()
this.setState({ mounts: res.data.mountPoints })
})
.catch(() => {})
}
/**
* get software versions and architecture info
*/
fetchVersionInfo = () => {
return API.request({
url: "/core/version"
})
.then(response => {
if (typeof response.data !== "object") throw new Error("invalid response")
this.setState({ version: response.data })
API.request({ url: '/core/version' })
.then(res => {
if (typeof res.data !== 'object') throw new Error()
this.setState({ version: res.data })
})
.catch(() => {})
}
/**
* gets the server stats
*/
fetchInfos = () => {
return API.request({
url: "/core/stats"
API.request({ url: '/core/stats' })
.then(({ data: stats }) => {
if (typeof stats !== 'object') throw new Error()
// store latest stats
this.setState({ stats })
// update history if transfers > 0
if (stats.transfers > 0) {
API.request({ url: '/core/transferred' })
.then(({ data }) => {
let history = Array.isArray(data.transferred)
? data.transferred
.sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at))
.filter(v => v.error === '' && v.bytes > 0)
: []
history = history.slice(0, 20) // keep last 20
this.setState({ transferred: history })
})
.then(response => {
if (typeof response.data !== "object") throw new Error("invalid response")
const stats = response.data
if (stats.transfers === 0) return this.setState({ stats })
return API.request({
url: "core/transferred"
})
.then(response => {
if (typeof response.data.transferred !== "object") throw new Error("invalid response")
let transferred = []
response.data.transferred.sort((a,b) => new Date(b.completed_at) - new Date(a.completed_at)).forEach(v => {
if (v.error.length === 0 && v.bytes > 0) return transferred.push(v)
})
this.setState({ transferred, stats })
})
.catch(() => this.setState({ stats }))
.catch(() => {})
}
})
.catch(() => {})
}
/**
* renders the total speed of all transfers
* @returns {String}
*/
renderLiveSpeed = () => {
const transferring = this.state.stats.transferring
if (typeof transferring !== "object") return "0.00 MB/s";
const list = Array.isArray(this.state.stats.transferring)
? this.state.stats.transferring
: []
let speed = 0
transferring.forEach(v => speed += v.speed)
list.forEach(item => {
speed += typeof item?.speed === 'number' ? item.speed : 0
})
return bytesToString(speed, { speed: true })
}
/**
* renders the list of remotes
* @returns {Component}
*/
renderRemotes = () => {
const { remotes } = this.state
if (remotes.length === 0) return null
return remotes.map(v => (
if (!remotes.length) return null
return remotes.map(r => (
<InfosRow
key={"mount" + v.name}
data-tip={bytesToString(v.bytes, { fixed: 2 })}
data-for={"size"+v.MountPoint}>
<p>{v.name}</p>
<p>{v.type}</p>
{/* add EDIT button */}
<ReactTooltip id={"size"+v.MountPoint} place="left" type="info" effect="solid" globalEventOff="click" />
key={r.name}
data-tip={bytesToString(r.bytes, { fixed: 2 })}
data-for={'size-' + r.name}
>
<p>{r.name}</p>
<p>{r.type}</p>
<ReactTooltip
id={'size-' + r.name}
place="left"
type="info"
effect="solid"
globalEventOff="click"
/>
</InfosRow>
))
}
/**
* renders the list of mounts
* @returns {Component}
*/
renderMounts = () => {
const { mounts } = this.state
if (mounts.length === 0) return null
return mounts.map(v => (
<Fragment key={v.MountPoint}>
<p>{v.Fs}</p>
<p>{v.MountPoint}</p>
if (!mounts.length) return null
return mounts.map(m => (
<Fragment key={m.MountPoint}>
<p>{m.Fs}</p>
<p>{m.MountPoint}</p>
</Fragment>
))
}
/**
* Each job gets it's own component and is rendered if there are any
* Jobs are found by their transfer group
* @returns {Job}
*/
renderActiveJobs = () => {
const transferring = this.state.stats.transferring
const { transferring = [], checking = [] } = this.state.stats
// collapse if nothing
if (!transferring.length && !checking.length) return null
// If it's not an array or empty, render nothing
if (!Array.isArray(transferring) || transferring.length === 0) {
return null
}
// group transfers by job
const groups = transferring.reduce((acc, item) => {
if (!item.group) return acc
acc[item.group] = acc[item.group] || []
acc[item.group].push(item)
return acc
}, {})
// Collect unique, defined group IDs
const activeJobIds = Array.from(
new Set(
transferring
.map(v => v.group)
.filter(g => typeof g === 'string')
)
)
return activeJobIds.map(group => {
// Protection: convert to string
const safeGroup = String(group)
const jobid = safeGroup.replace(/\D/g, '')
// All transfers in this group
const fileTransfers = transferring.filter(v => v.group === group)
return (
// map to <Job>
const transferJobs = Object.entries(groups).map(([grp, arr]) => (
<Job
key={safeGroup}
fileTransfers={fileTransfers}
jobid={jobid}
key={grp}
jobid={grp.replace(/\D/g, '')}
refreshStats={this.fetchInfos}
initialStats={{ transferring: arr }}
/>
)
})
))
// now render checking rows
const checkingRows = checking.map((name, i) => (
<ActiveChecking key={i}>
<p>{name}</p>
<p></p>
<p>Checking</p>
<p></p>
</ActiveChecking>
))
// if no transfers but checks present, just show checks
if (!transferJobs.length && checkingRows.length) {
return <Fragment>{checkingRows}</Fragment>
}
/**
* Renders all history items
* @returns {Component[]}
*/
renderHistory = () => {
return this.state.transferred.map((v, i) => (
<HistoryItem key={"transfer" + i}>
// otherwise show transfers then checks
return (
<Fragment>
{transferJobs}
{checkingRows}
</Fragment>
)
}
renderHistory = () =>
this.state.transferred.map((v, i) => (
<HistoryItem key={i}>
<p>{v.name}</p>
<p>{new Date(v.completed_at).toLocaleString()}</p>
</HistoryItem>
))
}
render = () => {
render() {
const { stats, version, endPointAvailable, remotes, mounts } = this.state
const { elapsedTime, transfers, bytes, errors, lastError, transferring } = stats
return (
<Fragment>
{
// this.renderFileBrowser()
}
<HeaderContainer>
<LogoContainer>
<img src="/favicon-64x64.png" alt="Rclone Dashboard" width="64" height="64" />
<h1>Rclone Dashboard</h1>
</LogoContainer>
<StatusContainer>
<StatusBulb style={{ background: endPointAvailable ? "var(--status-green)" : "var(--status-red)" }} />
{ endPointAvailable ? "API endpoint is behaving normally" : "API endpoint is unavailable" }
<StatusBulb
style={{
background: endPointAvailable ? 'var(--status-green)' : 'var(--status-red)'
}}
/>
{endPointAvailable
? 'API endpoint is behaving normally'
: 'API endpoint is unavailable'}
</StatusContainer>
</HeaderContainer>
@ -384,32 +270,33 @@ class App extends Component {
<Container>
<ItemsContainer>
<ActiveContainer>
<h1> Active Jobs </h1>
<h1>
Active Jobs
{/* show spinner if any checking or transferring */}
{(
(this.state.stats.transferring?.length || 0) +
(this.state.stats.checking?.length || 0)
) > 0 && <Spinner />}
</h1>
{this.renderActiveJobs()}
</ActiveContainer>
<HistoryContainer>
<h1>History</h1>
<HistoryItemsWrapper>
{ this.renderHistory() }
</HistoryItemsWrapper>
<HistoryItemsWrapper>{this.renderHistory()}</HistoryItemsWrapper>
</HistoryContainer>
</ItemsContainer>
<InfosContainer>
<InfosWrapper style={{ minHeight: "10rem" }}>
<InfosWrapper style={{ minHeight: '10rem' }}>
<h2>Service Stats</h2>
<p>Uptime</p>
<p>{secondsToTimeString(elapsedTime, true)}</p>
<p>Speed</p>
<p>{this.renderLiveSpeed()}</p>
<p>Active transfers</p>
<p> { (transferring?.length ? transferring.length : 0) + (transferring?.length === 1 ? " file" : " files" )} </p>
<p> Total transfered files </p>
<p> { (transfers ? transfers : 0) + (transfers === 1 ? " file" : " files" )} </p>
<p>{`${(transferring || []).length} files`}</p>
<p>Total transferred files</p>
<p>{`${transfers || 0} files`}</p>
<p>Total transferred data</p>
<p>{bytesToString(bytes, {})}</p>
</InfosWrapper>
@ -418,30 +305,27 @@ class App extends Component {
<h2>Environment</h2>
<p>Rclone version</p>
<p>{version.version}</p>
<p>GO version</p>
<p>{version.goVersion}</p>
<p>Architecture</p>
<p>{version.arch}</p>
</InfosWrapper>
{remotes.length > 0 && (
<InfosWrapper style={{ minHeight: "6rem" }}>
<h2> Remotes </h2> {/* add NEW button */}
<InfosWrapper style={{ minHeight: '6rem' }}>
<h2>Remotes</h2>
{this.renderRemotes()}
</InfosWrapper>
)}
{mounts.length > 0 && (
<InfosWrapper style={{ minHeight: "4.5rem" }}>
<h2> Mounts </h2> {/* add NEW button */}
<InfosWrapper style={{ minHeight: '4.5rem' }}>
<h2>Mounts</h2>
{this.renderMounts()}
</InfosWrapper>
)}
<Settings />
</InfosContainer>
</Container>
</Fragment>

View File

@ -1,99 +1,76 @@
import { Component, Fragment } from "react"
import { ActiveJob, ActiveTransfer, StopButton } from "../styled"
import { Component, Fragment } from 'react'
import { ActiveJob, ActiveTransfer, StopButton } from '../styled'
import API from '../utils/API'
import bytesToString from "../utils/bytestring"
import secondsToTimeString from "../utils/timestring"
import bytesToString from '../utils/bytestring'
import secondsToTimeString from '../utils/timestring'
class Job extends Component {
constructor() {
super()
constructor(props) {
super(props)
this.state = {
jobid: props.jobid,
stats: {
elapsedTime: 0,
eta: 3600,
bytes: 1024 * 1024 * 128,
totalBytes: 1024 * 1024 * 1024,
transferring: [
{
speed: 23534245,
group: "job/1",
size: 23457826345,
eta: 3600,
name: "a very very very long absurdly unneccesarily long name this is if you cant tell"
eta: 0,
bytes: 0,
totalBytes: 0,
transferring: props.initialStats?.transferring ?? []
}
]
},
jobid: 0
}
this.fetchStatsInterval = null
this.decreaseEtaInterval = null
}
this.decreaseEtaInterval = undefined
this.fetchStatsInterval = undefined
}
componentDidMount = () => {
componentDidMount() {
if (this.state.jobid) {
this.fetchStats()
// make the view look more responsive by counting every second
if (this.decreaseEtaInterval === undefined) this.decreaseEtaInterval = setInterval(() => {
let { stats } = this.state
if (stats.eta > 1) stats.eta--
stats.elapsedTime++
stats.transferring?.forEach(v => {if (v.eta > 1) v.eta--})
this.setState({ stats })
this.fetchStatsInterval = setInterval(this.fetchStats, 5000)
}
this.decreaseEtaInterval = setInterval(() => {
this.setState(({ stats }) => ({
stats: {
...stats,
elapsedTime: stats.elapsedTime + 1,
eta: Math.max(0, stats.eta - 1),
transferring: (stats.transferring || []).map(v => ({
...v,
eta: Math.max(0, v.eta - 1)
}))
}
}))
}, 1000)
// get job stats every 5 seconds
if (this.fetchStatsInterval === undefined) this.fetchStatsInterval = setInterval(this.fetchStats, 5 * 1000)
}
// clear the intervals
componentWillUnmount = () => {
clearInterval(this.decreaseEtaInterval)
componentWillUnmount() {
clearInterval(this.fetchStatsInterval)
clearInterval(this.decreaseEtaInterval)
}
/**
* get the stats from the job this component displays
*/
fetchStats = () => {
return API.request({
url: "/core/stats",
data: {
group: "job/" + this.props.jobid
fetchStats = () =>
API.request({ url: '/core/stats', data: { group: `job/${this.state.jobid}` } })
.then(({ data }) => {
if (typeof data === 'object') {
this.setState({
stats: {
elapsedTime: data.elapsedTime ?? 0,
eta: data.eta ?? 0,
bytes: data.bytes ?? 0,
totalBytes: data.totalBytes ?? 0,
transferring: Array.isArray(data.transferring) ? data.transferring : []
}
})
.then(response => {
if (typeof response.data !== "object") return new Error("invalid response")
this.setState({ stats: response.data })
}
})
.catch(() => {})
}
/**
* Immediately cancels the current job
*/
stopJob = () => {
return API.request({
url: "/job/stop",
data: {
jobid: this.props.jobid
}
})
stopJob = () =>
API.request({ url: '/job/stop', data: { jobid: this.state.jobid } })
.then(() => this.props.refreshStats())
.catch(() => {})
}
/**
* render each transfer with info
*/
renderActiveTransfer = () => {
const { transferring } = this.state.stats
if (typeof transferring !== "object") return;
renderActiveTransfer() {
const { transferring = [] } = this.state.stats
if (!transferring.length) return null
return transferring.map(v => (
<ActiveTransfer key={v.name}>
<p>{v.name}</p>
@ -104,28 +81,33 @@ class Job extends Component {
))
}
render = () => {
const { stats } = this.state
render() {
const { stats, jobid } = this.state
return (
<Fragment>
<ActiveJob>
<p>Time elapsed:</p>
<p>{secondsToTimeString(stats.elapsedTime)}</p>
<p>Progress:</p>
<p> { bytesToString(stats.bytes, { format: "GB", fixed: 3 }) } / { bytesToString(stats.totalBytes, { format: "GB", fixed: 3 }) } GB </p>
<p>
{bytesToString(stats.bytes, { format: 'GB', fixed: 3 })} /{' '}
{bytesToString(stats.totalBytes, { format: 'GB', fixed: 3 })} GB
</p>
<p>Time left:</p>
<p>{secondsToTimeString(stats.eta)}</p>
<p>Progress:</p>
<p> { ((stats.bytes / stats.totalBytes) * 100 || 0).toFixed(2) } % </p>
<p>{((stats.bytes / stats.totalBytes) * 100).toFixed(2)} %</p>
<p>Speed:</p>
<p>{bytesToString(stats.speed, { speed: true })}</p>
<StopButton onClick={this.stopJob}> Cancel </StopButton>
{jobid ? <StopButton onClick={this.stopJob}>Cancel</StopButton> : null}
</ActiveJob>
{/* Transfers only */}
{this.renderActiveTransfer()}
</Fragment>
)

View File

@ -141,6 +141,28 @@ export const ActiveTransfer = styled.div`
}
`
/* Same grid but a softer colour so checks are distinguishable */
export const ActiveChecking = styled(ActiveTransfer)`
background: var(--tertiary-color-trans);
`;
export const Spinner = styled.div`
display: inline-block;
width: 1.2em; /* size relative to font */
height: 1.2em;
margin-left: 0.5em; /* space after the text */
vertical-align: middle; /* align with text baseline */
border: 0.2em solid var(--secondary-color-trans);
border-top: 0.2em solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`
export const HistoryContainer = styled(ActiveContainer)`
margin-top: 1rem;
`