diff --git a/.eslintrc.js b/.eslintrc.js index 535fdc17..3d49eef6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,6 +23,8 @@ module.exports = { 'no-param-reassign': 0, 'no-plusplus': 0, 'no-underscore-dangle': [2, {allow: ['_id']}], + 'no-unused-vars': [0, { "argsIgnorePattern": "^_" }], + 'object-curly-newline': 0, 'object-curly-spacing': 0, 'prefer-destructuring': [ diff --git a/client/src/javascript/actions/FloodActions.js b/client/src/javascript/actions/FloodActions.js index 9b1d7c4f..66158841 100644 --- a/client/src/javascript/actions/FloodActions.js +++ b/client/src/javascript/actions/FloodActions.js @@ -45,6 +45,8 @@ const FloodActions = { this.handleClientConnectivityStatusChange, ); + activityStreamEventSource.removeEventListener(serverEventTypes.DISK_USAGE_CHANGE, this.handleDiskUsageChange); + activityStreamEventSource.removeEventListener( serverEventTypes.NOTIFICATION_COUNT_CHANGE, this.handleNotificationCountChange, @@ -148,7 +150,12 @@ const FloodActions = { data: JSON.parse(event.data), }); }, - + handleDiskUsageChange(event) { + AppDispatcher.dispatchServerAction({ + type: ActionTypes.DISK_USAGE_CHANGE, + data: JSON.parse(event.data), + }); + }, handleNotificationCountChange(event) { AppDispatcher.dispatchServerAction({ type: ActionTypes.NOTIFICATION_COUNT_CHANGE, @@ -233,6 +240,8 @@ const FloodActions = { this.handleClientConnectivityStatusChange, ); + activityStreamEventSource.addEventListener(serverEventTypes.DISK_USAGE_CHANGE, this.handleDiskUsageChange); + activityStreamEventSource.addEventListener( serverEventTypes.NOTIFICATION_COUNT_CHANGE, this.handleNotificationCountChange, diff --git a/client/src/javascript/components/general/Size.js b/client/src/javascript/components/general/Size.js index 85309cf0..b3a29daa 100644 --- a/client/src/javascript/components/general/Size.js +++ b/client/src/javascript/components/general/Size.js @@ -13,7 +13,7 @@ class Size extends React.Component { } render() { - const {value, isSpeed, precision, intl} = this.props; + const {value, isSpeed, className, precision, intl} = this.props; const computed = compute(value, precision); let translatedUnit = intl.formatMessage({id: getTranslationString(computed.unit)}); @@ -31,7 +31,7 @@ class Size extends React.Component { } return ( - + {this.renderNumber(computed)} {translatedUnit} diff --git a/client/src/javascript/components/sidebar/DiskUsage.js b/client/src/javascript/components/sidebar/DiskUsage.js new file mode 100644 index 00000000..2ff75420 --- /dev/null +++ b/client/src/javascript/components/sidebar/DiskUsage.js @@ -0,0 +1,81 @@ +import {FormattedMessage} from 'react-intl'; +import React from 'react'; + +import EventTypes from '../../constants/EventTypes'; +import DiskUsageStore from '../../stores/DiskUsageStore'; +import Size from '../general/Size'; +import Tooltip from '../general/Tooltip'; +import connectStores from '../../util/connectStores'; +import ProgressBar from '../general/ProgressBar'; + +const DiskUsageTooltipItem = ({label, value}) => { + return ( +
  • + + +
  • + ); +}; + +class DiskUsage extends React.Component { + getDisks() { + return this.props.disks.map(d => { + return ( +
  • + + } + /> + } + /> + } + /> + + } + position="top" + wrapperClassName="diskusage__item"> +
    + {d.target} + {Math.round((100 * d.used) / d.size)}% +
    + +
    +
  • + ); + }); + } + + render() { + const disks = this.getDisks(); + + if (disks.length === 0) { + return null; + } + + return ( + + ); + } +} + +export default connectStores(DiskUsage, () => [ + { + store: DiskUsageStore, + event: EventTypes.DISK_USAGE_CHANGE, + getValue: ({store}) => ({ + disks: store.getDiskUsage(), + }), + }, +]); diff --git a/client/src/javascript/components/sidebar/Sidebar.js b/client/src/javascript/components/sidebar/Sidebar.js index 9dae4a9b..6fab9b41 100644 --- a/client/src/javascript/components/sidebar/Sidebar.js +++ b/client/src/javascript/components/sidebar/Sidebar.js @@ -12,6 +12,7 @@ import SpeedLimitDropdown from './SpeedLimitDropdown'; import StatusFilters from './StatusFilters'; import TagFilters from './TagFilters'; import TrackerFilters from './TrackerFilters'; +import DiskUsage from './DiskUsage'; const Sidebar = () => { return ( @@ -28,6 +29,7 @@ const Sidebar = () => { + ); }; diff --git a/client/src/javascript/constants/ActionTypes.js b/client/src/javascript/constants/ActionTypes.js index 7d609828..ac3ad7fe 100644 --- a/client/src/javascript/constants/ActionTypes.js +++ b/client/src/javascript/constants/ActionTypes.js @@ -17,6 +17,7 @@ const actionTypes = [ 'CLIENT_ADD_TORRENT_SUCCESS', 'CLIENT_CHECK_HASH_ERROR', 'CLIENT_CHECK_HASH_SUCCESS', + 'DISK_USAGE_CHANGE', 'FLOOD_CLEAR_NOTIFICATIONS_ERROR', 'FLOOD_CLEAR_NOTIFICATIONS_SUCCESS', 'CLIENT_CONNECTION_TEST_ERROR', diff --git a/client/src/javascript/constants/EventTypes.js b/client/src/javascript/constants/EventTypes.js index 4669df60..8b1da772 100644 --- a/client/src/javascript/constants/EventTypes.js +++ b/client/src/javascript/constants/EventTypes.js @@ -41,6 +41,7 @@ const eventTypes = [ 'CLIENT_TRANSFER_HISTORY_REQUEST_SUCCESS', 'CLIENT_TRANSFER_HISTORY_REQUEST_ERROR', 'CLIENT_TRANSFER_SUMMARY_CHANGE', + 'DISK_USAGE_CHANGE', 'FLOOD_FETCH_MEDIAINFO_SUCCESS', 'NOTIFICATIONS_FETCH_ERROR', 'NOTIFICATIONS_FETCH_SUCCESS', diff --git a/client/src/javascript/stores/DiskUsageStore.js b/client/src/javascript/stores/DiskUsageStore.js new file mode 100644 index 00000000..5baf9e25 --- /dev/null +++ b/client/src/javascript/stores/DiskUsageStore.js @@ -0,0 +1,35 @@ +import BaseStore from './BaseStore'; +import ActionTypes from '../constants/ActionTypes'; +import EventTypes from '../constants/EventTypes'; +import AppDispatcher from '../dispatcher/AppDispatcher'; + +class DiskUsageStoreClass extends BaseStore { + constructor() { + super(); + this.disks = []; + } + + setDiskUsage(disks) { + this.disks = disks; + this.emit(EventTypes.DISK_USAGE_CHANGE); + } + + getDiskUsage() { + return this.disks; + } +} + +const DiskUsageStore = new DiskUsageStoreClass(); + +DiskUsageStore.dispatcherID = AppDispatcher.register(payload => { + const {action} = payload; + switch (action.type) { + case ActionTypes.DISK_USAGE_CHANGE: + DiskUsageStore.setDiskUsage(action.data); + break; + default: + break; + } +}); + +export default DiskUsageStore; diff --git a/client/src/sass/components/_sidebar.scss b/client/src/sass/components/_sidebar.scss index 1689b5d5..48762f75 100644 --- a/client/src/sass/components/_sidebar.scss +++ b/client/src/sass/components/_sidebar.scss @@ -12,7 +12,6 @@ $sidebar--icon-button--foreground: rgba($sidebar--foreground, 0.7); $sidebar--icon-button--foreground--hover: $blue; .application { - &__sidebar { background: $sidebar--background; box-shadow: 1px 0 $sidebar--border; @@ -27,7 +26,6 @@ $sidebar--icon-button--foreground--hover: $blue; } .sidebar { - &__icon-button { color: $sidebar--icon-button--foreground; display: block; @@ -61,7 +59,6 @@ $sidebar--icon-button--foreground--hover: $blue; } &__action { - &--last { margin-left: auto; } @@ -72,19 +69,55 @@ $sidebar--icon-button--foreground--hover: $blue; padding: $spacing-unit * 1/5; justify-content: flex-start; } + + &__diskusage { + .progress-bar__icon { + display: none; + } + .progress-bar__fill__wrapper { + background: rgba($sidebar-filter--foreground, 0.5); + } + .progress-bar__fill { + background: $sidebar-filter--foreground; + } + } +} + +.diskuage__size-avail { + margin-left: 1em; +} + +.diskusage__text-row { + display: flex; + justify-content: space-between; +} + +.diskusage { + &__details-list { + display: flex; + + &__item { + & + .diskusage__details-list__item { + margin-left: 10px; + } + } + + &__label { + color: $light-grey; + display: block; + font-size: 0.9em; + font-weight: 600; + } + } } .dropdown { - &--speed-limits { - .dropdown { - &__content { min-width: 180px; .sidebar { - &__icon-button { padding: $spacing-unit * 2/5; diff --git a/config.template.js b/config.template.js index e47d125f..823c8f5c 100644 --- a/config.template.js +++ b/config.template.js @@ -41,6 +41,13 @@ const CONFIG = { ssl: false, sslKey: '/absolute/path/to/key/', sslCert: '/absolute/path/to/certificate/', + // disk space service checks disk space of mounted partitions + diskUsageService: { + // assign desired mounts to include. Refer to "Mounted on" column of `df -P` + // watchMountPoints: [ + // "/mnt/disk" + // ] + } }; // Do not remove the below line. module.exports = CONFIG; diff --git a/package-lock.json b/package-lock.json index 0f4949b0..c9f658b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6454,7 +6454,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -6472,11 +6473,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6489,15 +6492,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -6600,7 +6606,8 @@ }, "inherits": { "version": "2.0.3", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -6610,6 +6617,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -6622,17 +6630,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -6649,6 +6660,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -6721,7 +6733,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -6731,6 +6744,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -6806,7 +6820,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -6836,6 +6851,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -6853,6 +6869,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -6891,11 +6908,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.0.3", - "bundled": true + "bundled": true, + "optional": true } } }, @@ -10171,7 +10190,7 @@ "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "integrity": "sha1-UWbihkV/AzBgZL5Ul+jbsMPTIIM=", "requires": { "brace-expansion": "^1.1.7" } @@ -14009,7 +14028,7 @@ "sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + "integrity": "sha1-KBYjTiN4vdxOU1T6tcqold9xANk=" }, "saxen": { "version": "8.1.0", diff --git a/server/constants/diskUsageServiceEvents.js b/server/constants/diskUsageServiceEvents.js new file mode 100644 index 00000000..fe4bad17 --- /dev/null +++ b/server/constants/diskUsageServiceEvents.js @@ -0,0 +1,5 @@ +const objectUtil = require('../../shared/util/objectUtil'); + +const diskUsageServiceEvents = ['DISK_USAGE_CHANGE']; + +module.exports = objectUtil.createSymbolMapFromArray(diskUsageServiceEvents); diff --git a/server/middleware/clientActivityStream.js b/server/middleware/clientActivityStream.js index 963b2d2a..6b8ed7a5 100644 --- a/server/middleware/clientActivityStream.js +++ b/server/middleware/clientActivityStream.js @@ -7,6 +7,8 @@ const serverEventTypes = require('../../shared/constants/serverEventTypes'); const services = require('../services'); const taxonomyServiceEvents = require('../constants/taxonomyServiceEvents'); const torrentServiceEvents = require('../constants/torrentServiceEvents'); +const diskUsageServiceEvents = require('../constants/diskUsageServiceEvents'); +const DiskUsageService = require('../services/diskUsageService'); module.exports = (req, res) => { const { @@ -20,11 +22,13 @@ module.exports = (req, res) => { const torrentList = serviceInstances.torrentService.getTorrentList(); const transferSummary = serviceInstances.historyService.getTransferSummary(); - // Remove all previous event listeners. - serviceInstances.historyService.removeAllListeners(); - serviceInstances.notificationService.removeAllListeners(); - serviceInstances.taxonomyService.removeAllListeners(); - serviceInstances.torrentService.removeAllListeners(); + // Hook into events and stop listening when connection is closed + const handleEvents = (emitter, event, handler) => { + emitter.on(event, handler); + res.on('close', () => { + emitter.removeListener(event, handler); + }); + }; // Emit current state immediately on connection. serverEvent.setID(Date.now()); @@ -32,6 +36,22 @@ module.exports = (req, res) => { serverEvent.addData({isConnected: !serviceInstances.clientGatewayService.hasError}); serverEvent.emit(); + const handleDiskUsageChange = diskUsageChange => { + serverEvent.setID(diskUsageChange.id); + serverEvent.setType(serverEventTypes.DISK_USAGE_CHANGE); + serverEvent.addData(diskUsageChange.disks); + serverEvent.emit(); + }; + + DiskUsageService.updateDisks().then(() => { + const diskUsage = DiskUsageService.getDiskUsage(); + serverEvent.setID(diskUsage.id); + serverEvent.setType(serverEventTypes.DISK_USAGE_CHANGE); + serverEvent.addData(diskUsage.disks); + serverEvent.emit(); + handleEvents(DiskUsageService, diskUsageServiceEvents.DISK_USAGE_CHANGE, handleDiskUsageChange); + }); + serverEvent.setID(torrentList.id); serverEvent.setType(serverEventTypes.TORRENT_LIST_FULL_UPDATE); serverEvent.addData(torrentList.torrents); @@ -52,7 +72,7 @@ module.exports = (req, res) => { serverEvent.addData(serviceInstances.notificationService.getNotificationCount()); serverEvent.emit(); - serviceInstances.clientGatewayService.on(clientGatewayServiceEvents.CLIENT_CONNECTION_STATE_CHANGE, () => { + handleEvents(serviceInstances.clientGatewayService, clientGatewayServiceEvents.CLIENT_CONNECTION_STATE_CHANGE, () => { serverEvent.setID(Date.now()); serverEvent.setType(serverEventTypes.CLIENT_CONNECTIVITY_STATUS_CHANGE); serverEvent.addData({isConnected: !serviceInstances.clientGatewayService.hasError}); @@ -78,7 +98,8 @@ module.exports = (req, res) => { }); // Add user's specified history snapshot change event listener. - serviceInstances.historyService.on( + handleEvents( + serviceInstances.historyService, historyServiceEvents[`${historySnapshotTypes[historySnapshot]}_SNAPSHOT_FULL_UPDATE`], payload => { const {data, id} = payload; @@ -90,7 +111,7 @@ module.exports = (req, res) => { }, ); - serviceInstances.notificationService.on(notificationServiceEvents.NOTIFICATION_COUNT_CHANGE, payload => { + handleEvents(serviceInstances.notificationService, notificationServiceEvents.NOTIFICATION_COUNT_CHANGE, payload => { const {data, id} = payload; serverEvent.setID(id); @@ -100,7 +121,7 @@ module.exports = (req, res) => { }); // Add diff event listeners. - serviceInstances.historyService.on(historyServiceEvents.TRANSFER_SUMMARY_DIFF_CHANGE, payload => { + handleEvents(serviceInstances.historyService, historyServiceEvents.TRANSFER_SUMMARY_DIFF_CHANGE, payload => { const {diff, id} = payload; serverEvent.setID(id); @@ -109,7 +130,7 @@ module.exports = (req, res) => { serverEvent.emit(); }); - serviceInstances.taxonomyService.on(taxonomyServiceEvents.TAXONOMY_DIFF_CHANGE, payload => { + handleEvents(serviceInstances.taxonomyService, taxonomyServiceEvents.TAXONOMY_DIFF_CHANGE, payload => { const {diff, id} = payload; serverEvent.setID(id); @@ -118,7 +139,7 @@ module.exports = (req, res) => { serverEvent.emit(); }); - serviceInstances.torrentService.on(torrentServiceEvents.TORRENT_LIST_DIFF_CHANGE, payload => { + handleEvents(serviceInstances.torrentService, torrentServiceEvents.TORRENT_LIST_DIFF_CHANGE, payload => { const {diff, id} = payload; serverEvent.setID(id); diff --git a/server/services/diskUsageService.js b/server/services/diskUsageService.js new file mode 100644 index 00000000..4f6b1690 --- /dev/null +++ b/server/services/diskUsageService.js @@ -0,0 +1,116 @@ +/** + * This service is not per rtorrent session, which is why it does not inherit + * `BaseService` nor have any use of the per user API ie. `getSerivce()` + */ +const EventEmitter = require('events'); +const util = require('util'); +const execFile = util.promisify(require('child_process').execFile); +const config = require('../../config'); +const diskUsageServiceEvents = require('../constants/diskUsageServiceEvents'); + +const PLATFORMS_SUPPORTED = ['darwin', 'linux']; + +const filterMountPoint = + config.diskUsageService && config.diskUsageService.watchMountPoints + ? // if user has configured watchPartitions filter each line output for given + // array + mountpoint => config.diskUsageService.watchMountPoints.includes(mountpoint) + : () => true; // include all mounted file systems by default + +const diskUsage = { + linux: () => + execFile('df --block-size=1024 --portability | tail -n+2', { + shell: true, + maxBuffer: 4096, + }).then(({stdout}) => + stdout + .trim() + .split('\n') + .map(disk => disk.split(/\s+/)) + .filter(disk => filterMountPoint(disk[5])) + .map(([_fs, size, used, avail, _pcent, target]) => { + return { + size: Number.parseInt(size, 10) * 1024, + used: Number.parseInt(used, 10) * 1024, + avail: Number.parseInt(avail, 10) * 1024, + target, + }; + }), + ), + darwin: () => + execFile('df -kl | tail -n+2', { + shell: true, + maxBuffer: 4096, + }).then(({stdout}) => + stdout + .trim() + .split('\n') + .map(disk => disk.split(/\s+/)) + .filter(disk => filterMountPoint(disk[8])) + .map(([_fs, size, used, avail, _pcent, _iused, _ifree, _piused, target]) => { + return { + size: Number.parseInt(size, 10) * 1024, + used: Number.parseInt(used, 10) * 1024, + avail: Number.parseInt(avail, 10) * 1024, + target, + }; + }), + ), + // TODO: + win32: () => Promise.resolve([]), +}; + +const INTERVAL_UPDATE = 10000; + +class DiskUsageService extends EventEmitter { + constructor() { + super(); + this.disks = []; + this.tLastChange = 0; + this.interval = 0; + + if (!PLATFORMS_SUPPORTED.includes(process.platform)) { + console.log(`warning: DiskUsageService is only supported in ${PLATFORMS_SUPPORTED.join()}`); + return; + } + + // start polling disk usage when the first listener is added + this.on('newListener', event => { + if ( + this.listenerCount(diskUsageServiceEvents.DISK_USAGE_CHANGE) === 0 && + event === diskUsageServiceEvents.DISK_USAGE_CHANGE + ) { + this.updateInterval = setInterval(this.updateDisks.bind(this), INTERVAL_UPDATE); + } + }); + + // stop polling disk usage when the last listener is removed + this.on('removeListener', event => { + if ( + this.listenerCount(diskUsageServiceEvents.DISK_USAGE_CHANGE) === 0 && + event === diskUsageServiceEvents.DISK_USAGE_CHANGE + ) { + clearInterval(this.updateInterval); + } + }); + } + + updateDisks() { + return diskUsage[process.platform]().then(disks => { + if (disks.length !== this.disks.length || disks.some((d, i) => d.used !== this.disks[i].used)) { + this.tLastChange = Date.now(); + this.disks = disks; + this.emit(diskUsageServiceEvents.DISK_USAGE_CHANGE, this.getDiskUsage()); + } + }); + } + + getDiskUsage() { + return { + id: this.tLastChange, + disks: this.disks, + }; + } +} + +module.exports = new DiskUsageService(); diff --git a/shared/constants/serverEventTypes.js b/shared/constants/serverEventTypes.js index 0f7edcac..5843b3a3 100644 --- a/shared/constants/serverEventTypes.js +++ b/shared/constants/serverEventTypes.js @@ -2,6 +2,7 @@ const objectUtil = require('../util/objectUtil'); const serverEventTypes = [ 'CLIENT_CONNECTIVITY_STATUS_CHANGE', + 'DISK_USAGE_CHANGE', 'NOTIFICATION_COUNT_CHANGE', 'TAXONOMY_FULL_UPDATE', 'TAXONOMY_DIFF_CHANGE',