diff --git a/src/App.js b/src/App.js index cc7af92..618d03f 100644 --- a/src/App.js +++ b/src/App.js @@ -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 = undefined - this.timeInterval = undefined - this.apiInterval = undefined + this.infoInterval = null + this.apiInterval = null } - 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 + ":" - } - }) - .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) - }) + 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)) throw new Error() + remotes.push({ name, type: res.data[name].type, bytes: data.used }) + }) + ) + ).then(() => this.setState({ remotes })) }) - ) - .then(() => { - this.setState({ remotes }) - }) - .catch(err => console.error(err)) - }) - .catch(() => {}) + .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 }) - }) - .catch(() => {}) + 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 }) - }) - .catch(() => {}) + 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" - }) - .then(response => { - if (typeof response.data !== "object") throw new Error("invalid response") - const stats = response.data + API.request({ url: '/core/stats' }) + .then(({ data: stats }) => { + if (typeof stats !== 'object') throw new Error() - if (stats.transfers === 0) return this.setState({ stats }) + // store latest stats + this.setState({ stats }) - return API.request({ - url: "core/transferred" + // 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 }) + }) + .catch(() => {}) + } }) - .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 => ( - -

{v.name}

-

{v.type}

- {/* add EDIT button */} - + key={r.name} + data-tip={bytesToString(r.bytes, { fixed: 2 })} + data-for={'size-' + r.name} + > +

{r.name}

+

{r.type}

+
)) } - /** - * renders the list of mounts - * @returns {Component} - */ renderMounts = () => { const { mounts } = this.state - if (mounts.length === 0) return null - - return mounts.map(v => ( - -

{v.Fs}

-

{v.MountPoint}

+ if (!mounts.length) return null + return mounts.map(m => ( + +

{m.Fs}

+

{m.MountPoint}

)) } - /** - * 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 + }, {}) + + // map to + const transferJobs = Object.entries(groups).map(([grp, arr]) => ( + + )) + + // now render checking rows + const checkingRows = checking.map((name, i) => ( + +

{name}

+

+

Checking

+

+
+ )) + + // if no transfers but checks present, just show checks + if (!transferJobs.length && checkingRows.length) { + return {checkingRows} } - // Collect unique, defined group IDs - const activeJobIds = Array.from( - new Set( - transferring - .map(v => v.group) - .filter(g => typeof g === 'string') - ) + // otherwise show transfers then checks + return ( + + {transferJobs} + {checkingRows} + ) - - 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 ( - - ) - }) } - /** - * Renders all history items - * @returns {Component[]} - */ - renderHistory = () => { - return this.state.transferred.map((v, i) => ( - -

{ v.name }

-

{ new Date(v.completed_at).toLocaleString() }

+ renderHistory = () => + this.state.transferred.map((v, i) => ( + +

{v.name}

+

{new Date(v.completed_at).toLocaleString()}

)) - } - render = () => { + render() { const { stats, version, endPointAvailable, remotes, mounts } = this.state const { elapsedTime, transfers, bytes, errors, lastError, transferring } = stats return ( - { - // this.renderFileBrowser() - } - Rclone Dashboard -

Rclone Dashboard

+

Rclone Dashboard

- - - { endPointAvailable ? "API endpoint is behaving normally" : "API endpoint is unavailable" } + + {endPointAvailable + ? 'API endpoint is behaving normally' + : 'API endpoint is unavailable'}
@@ -383,65 +269,63 @@ class App extends Component { - -

Active Jobs

- { this.renderActiveJobs() } -
+ +

+ Active Jobs + {/* show spinner if any checking or transferring */} + {( + (this.state.stats.transferring?.length || 0) + + (this.state.stats.checking?.length || 0) + ) > 0 && } +

+ {this.renderActiveJobs()} +
-

History

- - { this.renderHistory() } - +

History

+ {this.renderHistory()}
- -

Service Stats

-

Uptime

-

{ secondsToTimeString(elapsedTime, true) }

- -

Speed

-

{ this.renderLiveSpeed() }

- -

Active transfers

-

{ (transferring?.length ? transferring.length : 0) + (transferring?.length === 1 ? " file" : " files" )}

- -

Total transfered files

-

{ (transfers ? transfers : 0) + (transfers === 1 ? " file" : " files" )}

- -

Total transferred data

-

{ bytesToString(bytes, {}) }

+ +

Service Stats

+

Uptime

+

{secondsToTimeString(elapsedTime, true)}

+

Speed

+

{this.renderLiveSpeed()}

+

Active transfers

+

{`${(transferring || []).length} files`}

+

Total transferred files

+

{`${transfers || 0} files`}

+

Total transferred data

+

{bytesToString(bytes, {})}

-

Environment

-

Rclone version

-

{ version.version }

- -

GO version

-

{ version.goVersion }

- -

Architecture

-

{ version.arch }

+

Environment

+

Rclone version

+

{version.version}

+

GO version

+

{version.goVersion}

+

Architecture

+

{version.arch}

{remotes.length > 0 && ( - -

Remotes

{/* add NEW button */} - { this.renderRemotes() } + +

Remotes

+ {this.renderRemotes()}
)} {mounts.length > 0 && ( - -

Mounts

{/* add NEW button */} - { this.renderMounts() } + +

Mounts

+ {this.renderMounts()}
)} -
diff --git a/src/components/job.jsx b/src/components/job.jsx index a24348a..1d86c1d 100644 --- a/src/components/job.jsx +++ b/src/components/job.jsx @@ -1,132 +1,114 @@ -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" - } - ] - }, - jobid: 0 + eta: 0, + bytes: 0, + totalBytes: 0, + transferring: props.initialStats?.transferring ?? [] + } } - - this.decreaseEtaInterval = undefined - this.fetchStatsInterval = undefined + this.fetchStatsInterval = null + this.decreaseEtaInterval = null } - componentDidMount = () => { - 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 }) + componentDidMount() { + if (this.state.jobid) { + this.fetchStats() + 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 - } - }) - .then(response => { - if (typeof response.data !== "object") return new Error("invalid response") + 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 : [] + } + }) + } + }) + .catch(() => {}) - this.setState({ stats: response.data }) - }) - .catch(() => {}) - } - - /** - * Immediately cancels the current job - */ - stopJob = () => { - return API.request({ - url: "/job/stop", - data: { - jobid: this.props.jobid - } - }) - .then(() => this.props.refreshStats()) - .catch(() => {}) - } - - /** - * render each transfer with info - */ - renderActiveTransfer = () => { - const { transferring } = this.state.stats - - if (typeof transferring !== "object") return; + stopJob = () => + API.request({ url: '/job/stop', data: { jobid: this.state.jobid } }) + .then(() => this.props.refreshStats()) + .catch(() => {}) + renderActiveTransfer() { + const { transferring = [] } = this.state.stats + if (!transferring.length) return null return transferring.map(v => ( -

{ v.name }

-

{ bytesToString(v.size, {}) }

-

{ secondsToTimeString(v.eta) }

-

{ bytesToString(v.speed, { speed: true }) }

+

{v.name}

+

{bytesToString(v.size, {})}

+

{secondsToTimeString(v.eta)}

+

{bytesToString(v.speed, { speed: true })}

)) } - render = () => { - const { stats } = this.state - + render() { + const { stats, jobid } = this.state return ( + +

Time elapsed:

+

{secondsToTimeString(stats.elapsedTime)}

- -

Time elapsed:

-

{ secondsToTimeString(stats.elapsedTime) }

-

Progress:

-

{ bytesToString(stats.bytes, { format: "GB", fixed: 3 }) } / { bytesToString(stats.totalBytes, { format: "GB", fixed: 3 }) } GB

+

Progress:

+

+ {bytesToString(stats.bytes, { format: 'GB', fixed: 3 })} /{' '} + {bytesToString(stats.totalBytes, { format: 'GB', fixed: 3 })} GB +

-

Time left:

-

{ secondsToTimeString(stats.eta) }

-

Progress:

-

{ ((stats.bytes / stats.totalBytes) * 100 || 0).toFixed(2) } %

+

Time left:

+

{secondsToTimeString(stats.eta)}

-

Speed:

-

{ bytesToString(stats.speed, { speed: true }) }

+

Progress:

+

{((stats.bytes / stats.totalBytes) * 100).toFixed(2)} %

- Cancel -
- { this.renderActiveTransfer() } +

Speed:

+

{bytesToString(stats.speed, { speed: true })}

+ + {jobid ? Cancel : null} +
+ + {/* Transfers only */} + {this.renderActiveTransfer()}
) } diff --git a/src/styled.js b/src/styled.js index b3c2aaf..665c5ae 100644 --- a/src/styled.js +++ b/src/styled.js @@ -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; `