diff --git a/frontend/src/components/common/account.js b/frontend/src/components/common/account.js
index cfad00b5cf..5e9431561c 100644
--- a/frontend/src/components/common/account.js
+++ b/frontend/src/components/common/account.js
@@ -122,7 +122,7 @@ class Account extends Component {
};
} else if (isOrgStaff) {
data = {
- url: `${siteRoot}org/useradmin/`,
+ url: `${siteRoot}org/info/`,
text: gettext('Organization Admin')
};
} else if (isInstAdmin) {
diff --git a/frontend/src/components/dialog/org-import-users-dialog.js b/frontend/src/components/dialog/org-import-users-dialog.js
new file mode 100644
index 0000000000..acadd65037
--- /dev/null
+++ b/frontend/src/components/dialog/org-import-users-dialog.js
@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Alert, Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap';
+import { gettext, siteRoot } from '../../utils/constants';
+
+const propTypes = {
+ toggle: PropTypes.func.isRequired,
+ importUsersInBatch: PropTypes.func.isRequired,
+};
+
+class ImportOrgUsersDialog extends React.Component {
+ constructor(props) {
+ super(props);
+ this.fileInputRef = React.createRef();
+ this.state = {
+ errorMsg: ''
+ };
+ }
+
+ toggle = () => {
+ this.props.toggle();
+ }
+
+ openFileInput = () => {
+ this.fileInputRef.current.click();
+ }
+
+ uploadFile = (e) => {
+ // no file selected
+ if (!this.fileInputRef.current.files.length) {
+ return;
+ }
+ // check file extension
+ let fileName = this.fileInputRef.current.files[0].name;
+ if(fileName.substr(fileName.lastIndexOf('.') + 1) != 'xlsx') {
+ this.setState({
+ errorMsg: gettext('Please choose a .xlsx file.')
+ });
+ return;
+ }
+ const file = this.fileInputRef.current.files[0];
+ this.props.importUsersInBatch(file);
+ this.toggle();
+ }
+
+ render() {
+ let { errorMsg } = this.state;
+ return (
+
+ {gettext('Import users from a .xlsx file')}
+
+ {gettext('Download an example file')}
+
+
+ {errorMsg && {errorMsg}}
+
+
+
+
+
+ );
+ }
+}
+
+ImportOrgUsersDialog.propTypes = propTypes;
+
+export default ImportOrgUsersDialog;
diff --git a/frontend/src/pages/org-admin/devices/desktop-devices.js b/frontend/src/pages/org-admin/devices/desktop-devices.js
new file mode 100644
index 0000000000..47ce6a20d3
--- /dev/null
+++ b/frontend/src/pages/org-admin/devices/desktop-devices.js
@@ -0,0 +1,29 @@
+import React, { Component, Fragment } from 'react';
+import DevicesNav from './devices-nav';
+import DevicesByPlatform from './devices-by-platform';
+import MainPanelTopbar from '../main-panel-topbar';
+
+class OrgDesktopDevices extends Component {
+
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ return (
+
+
+
+
+ );
+ }
+}
+
+export default OrgDesktopDevices;
diff --git a/frontend/src/pages/org-admin/devices/devices-by-platform.js b/frontend/src/pages/org-admin/devices/devices-by-platform.js
new file mode 100644
index 0000000000..691a2cb50d
--- /dev/null
+++ b/frontend/src/pages/org-admin/devices/devices-by-platform.js
@@ -0,0 +1,216 @@
+import React, { Component, Fragment } from 'react';
+import { seafileAPI } from '../../../utils/seafile-api';
+import { orgID, gettext } from '../../../utils/constants';
+import toaster from '../../../components/toast';
+import { Utils } from '../../../utils/utils';
+import EmptyTip from '../../../components/empty-tip';
+import moment from 'moment';
+import Loading from '../../../components/loading';
+import Paginator from '../../../components/paginator';
+import SysAdminUnlinkDevice from '../../../components/dialog/sysadmin-dialog/sysadmin-unlink-device-dialog';
+
+class Content extends Component {
+
+ constructor(props) {
+ super(props);
+ }
+
+ getPreviousPageDevicesList = () => {
+ this.props.getDevicesListByPage(this.props.pageInfo.current_page - 1);
+ }
+
+ getNextPageDevicesList = () => {
+ this.props.getDevicesListByPage(this.props.pageInfo.current_page + 1);
+ }
+
+ render() {
+ const { loading, errorMsg, items, pageInfo, curPerPage } = this.props;
+ if (loading) {
+ return ;
+ } else if (errorMsg) {
+ return
{errorMsg}
;
+ } else {
+ const emptyTip = (
+
+ {gettext('No connected devices')}
+
+ );
+ const table = (
+
+
+
+
+ {gettext('User')} |
+ {gettext('Platform')}{' / '}{gettext('Version')} |
+ {gettext('Device Name')} |
+ {gettext('IP')} |
+ {gettext('Last Access')} |
+ {/*Operations*/} |
+
+
+
+ {items.map((item, index) => {
+ return ( );
+ })}
+
+
+
+
+ );
+
+ return items.length ? table : emptyTip;
+ }
+ }
+}
+
+class Item extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ unlinked: false,
+ isOpIconShown: false,
+ isUnlinkDeviceDialogOpen: false
+ };
+ }
+
+ handleMouseOver = () => {
+ this.setState({isOpIconShown: true});
+ }
+
+ handleMouseOut = () => {
+ this.setState({isOpIconShown: false});
+ }
+
+ handleUnlink = (e) => {
+ e.preventDefault();
+ if (this.props.item.is_desktop_client) {
+ this.toggleUnlinkDeviceDialog();
+ } else {
+ this.unlinkDevice(true);
+ }
+ }
+
+ toggleUnlinkDeviceDialog = () => {
+ this.setState({isUnlinkDeviceDialogOpen: !this.state.isUnlinkDeviceDialogOpen});
+ }
+
+ unlinkDevice = (deleteFiles) => {
+ const { platform, device_id, user } = this.props.item;
+ seafileAPI.orgAdminUnlinkDevice(orgID, platform, device_id, user, deleteFiles).then((res) => {
+ this.setState({unlinked: true});
+ let message = gettext('Successfully unlinked the device.');
+ toaster.success(message);
+ }).catch((error) => {
+ let errMessage = Utils.getErrorMsg(error);
+ toaster.danger(errMessage);
+ });
+ }
+
+ render() {
+ const item = this.props.item;
+ const { unlinked, isUnlinkDeviceDialogOpen, isOpIconShown } = this.state;
+
+ if (unlinked) {
+ return null;
+ }
+
+ return (
+
+
+ {item.user_name} |
+ {item.platform}{' / '}{item.client_version} |
+ {item.device_name} |
+ {item.last_login_ip} |
+
+ {moment(item.last_accessed).fromNow()}
+ |
+
+
+ |
+
+ {isUnlinkDeviceDialogOpen &&
+
+ }
+
+ );
+ }
+}
+
+class DevicesByPlatform extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ loading: true,
+ errorMsg: '',
+ devicesData: {},
+ pageInfo: {},
+ perPage: 25
+ };
+ }
+
+ componentDidMount () {
+ let urlParams = (new URL(window.location)).searchParams;
+ const { currentPage = 1, perPage } = this.state;
+ this.setState({
+ perPage: parseInt(urlParams.get('per_page') || perPage),
+ currentPage: parseInt(urlParams.get('page') || currentPage)
+ }, () => {
+ this.getDevicesListByPage(this.state.currentPage);
+ });
+ }
+
+ getDevicesListByPage = (page) => {
+ let platform = this.props.devicesPlatform;
+ let per_page = this.state.perPage;
+ seafileAPI.orgAdminListDevices(orgID, platform, page, per_page).then((res) => {
+ this.setState({
+ devicesData: res.data.devices,
+ pageInfo: res.data.page_info,
+ loading: false
+ });
+ }).catch((error) => {
+ this.setState({
+ loading: false,
+ errorMsg: Utils.getErrorMsg(error, true) // true: show login tip if 403
+ });
+ });
+ }
+
+ resetPerPage = (perPage) => {
+ this.setState({
+ perPage: perPage
+ }, () => {
+ this.getDevicesListByPage(1);
+ });
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
+
+export default DevicesByPlatform;
diff --git a/frontend/src/pages/org-admin/devices/devices-errors.js b/frontend/src/pages/org-admin/devices/devices-errors.js
new file mode 100644
index 0000000000..cecf5f459f
--- /dev/null
+++ b/frontend/src/pages/org-admin/devices/devices-errors.js
@@ -0,0 +1,204 @@
+import React, { Component, Fragment } from 'react';
+import { Button } from 'reactstrap';
+import { seafileAPI } from '../../../utils/seafile-api';
+import { siteRoot, gettext, orgID } from '../../../utils/constants';
+import toaster from '../../../components/toast';
+import { Utils } from '../../../utils/utils';
+import EmptyTip from '../../../components/empty-tip';
+import moment from 'moment';
+import Loading from '../../../components/loading';
+import { Link } from '@reach/router';
+import DevicesNav from './devices-nav';
+import MainPanelTopbar from '../main-panel-topbar';
+import UserLink from '../user-link';
+import Paginator from '../../../components/paginator';
+
+class Content extends Component {
+
+ constructor(props) {
+ super(props);
+ }
+
+ getPreviousPageDeviceErrorsList = () => {
+ this.props.getDeviceErrorsListByPage(this.props.pageInfo.current_page - 1);
+ }
+
+ getNextPageDeviceErrorsList = () => {
+ this.props.getDeviceErrorsListByPage(this.props.pageInfo.current_page + 1);
+ }
+
+ render() {
+ const { loading, errorMsg, items, pageInfo, curPerPage } = this.props;
+ if (loading) {
+ return ;
+ } else if (errorMsg) {
+ return {errorMsg}
;
+ } else {
+ const emptyTip = (
+
+ {gettext('No sync errors')}
+
+ );
+ const table = (
+
+
+
+
+ {gettext('User')} |
+ {gettext('Device')}{' / '}{gettext('Version')} |
+ {gettext('IP')} |
+ {gettext('Library')} |
+ {gettext('Error')} |
+ {gettext('Time')} |
+
+
+
+ {items.map((item, index) => {
+ return ( );
+ })}
+
+
+
+
+ );
+ return items.length ? table : emptyTip;
+ }
+ }
+}
+
+class Item extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ isOpIconShown: false,
+ };
+ }
+
+ handleMouseOver = () => {
+ this.setState({isOpIconShown: true});
+ }
+
+ handleMouseOut = () => {
+ this.setState({isOpIconShown: false});
+ }
+
+ render() {
+ let item = this.props.item;
+ return (
+
+ |
+ {item.device_name}{' / '}{item.client_version} |
+ {item.device_ip} |
+ {item.repo_name} |
+ {item.error_msg} |
+
+ {moment(item.error_time).fromNow()}
+ |
+
+ );
+ }
+}
+
+class OrgDevicesErrors extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ loading: true,
+ errorMsg: '',
+ devicesErrors: [],
+ isCleanBtnShown: false,
+ pageInfo: {},
+ perPage: 25
+ };
+ }
+
+ componentDidMount () {
+ let urlParams = (new URL(window.location)).searchParams;
+ const { currentPage = 1, perPage } = this.state;
+ this.setState({
+ perPage: parseInt(urlParams.get('per_page') || perPage),
+ currentPage: parseInt(urlParams.get('page') || currentPage)
+ }, () => {
+ this.getDeviceErrorsListByPage(this.state.currentPage);
+ });
+ }
+
+ getDeviceErrorsListByPage = (page) => {
+ let per_page = this.state.perPage;
+ seafileAPI.orgAdminListDevicesErrors(orgID, page, per_page).then((res) => {
+ this.setState({
+ loading: false,
+ devicesErrors: res.data.device_errors,
+ pageInfo: res.data.page_info,
+ isCleanBtnShown: res.data.length > 0
+ });
+ }).catch((error) => {
+ this.setState({
+ loading: false,
+ errorMsg: Utils.getErrorMsg(error, true) // true: show login tip if 403
+ });
+ });
+ }
+
+ clean = () => {
+ seafileAPI.sysAdminClearDeviceErrors().then((res) => {
+ this.setState({
+ devicesErrors: [],
+ isCleanBtnShown: false
+ });
+ let message = gettext('Successfully cleaned all errors.');
+ toaster.success(message);
+ }).catch((error) => {
+ let errMessage = Utils.getErrorMsg(error);
+ toaster.danger(errMessage);
+ });
+ }
+
+ resetPerPage = (perPage) => {
+ this.setState({
+ perPage: perPage
+ }, () => {
+ this.getDeviceErrorsListByPage(1);
+ });
+ }
+ render() {
+ return (
+
+ {this.state.isCleanBtnShown ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+ }
+}
+
+export default OrgDevicesErrors;
diff --git a/frontend/src/pages/org-admin/devices/devices-nav.js b/frontend/src/pages/org-admin/devices/devices-nav.js
new file mode 100644
index 0000000000..263690a592
--- /dev/null
+++ b/frontend/src/pages/org-admin/devices/devices-nav.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Link } from '@reach/router';
+import { siteRoot, gettext } from '../../../utils/constants';
+
+const propTypes = {
+ currentItem: PropTypes.string.isRequired
+};
+
+class Nav extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.navItems = [
+ {name: 'desktop', urlPart:'desktop-devices', text: gettext('Desktop')},
+ {name: 'mobile', urlPart:'mobile-devices', text: gettext('Mobile')},
+ {name: 'errors', urlPart:'devices-errors', text: gettext('Errors')}
+ ];
+ }
+
+ render() {
+ const { currentItem } = this.props;
+ return (
+
+
+ {this.navItems.map((item, index) => {
+ return (
+ -
+ {item.text}
+
+ );
+ })}
+
+
+ );
+ }
+}
+
+Nav.propTypes = propTypes;
+
+export default Nav;
diff --git a/frontend/src/pages/org-admin/devices/mobile-devices.js b/frontend/src/pages/org-admin/devices/mobile-devices.js
new file mode 100644
index 0000000000..41a9f4d2c6
--- /dev/null
+++ b/frontend/src/pages/org-admin/devices/mobile-devices.js
@@ -0,0 +1,29 @@
+import React, { Component, Fragment } from 'react';
+import DevicesNav from './devices-nav';
+import DevicesByPlatform from './devices-by-platform';
+import MainPanelTopbar from '../main-panel-topbar';
+
+class MobileDevices extends Component {
+
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ return (
+
+
+
+
+ );
+ }
+}
+
+export default MobileDevices;
diff --git a/frontend/src/pages/org-admin/index.js b/frontend/src/pages/org-admin/index.js
index af3d949776..c039c5f699 100644
--- a/frontend/src/pages/org-admin/index.js
+++ b/frontend/src/pages/org-admin/index.js
@@ -3,6 +3,15 @@ import ReactDOM from 'react-dom';
import { Router } from '@reach/router';
import { siteRoot } from '../../utils/constants';
import SidePanel from './side-panel';
+
+import OrgStatisticFile from './statistic/statistic-file';
+import OrgStatisticStorage from './statistic/statistic-storage';
+import OrgStatisticTraffic from './statistic/statistic-traffic';
+import OrgStatisticUsers from './statistic/statistic-users';
+import OrgStatisticReport from './statistic/statistic-reports';
+import OrgDesktopDevices from './devices/desktop-devices.js';
+import OrgMobileDevices from './devices/mobile-devices.js';
+import OrgDevicesErrors from './devices/devices-errors.js';
import OrgUsers from './org-users-users';
import OrgUsersSearchUsers from './org-users-search-users';
import OrgAdmins from './org-users-admins';
@@ -44,6 +53,12 @@ class Org extends React.Component {
if (location.href.indexOf(`${siteRoot}org/useradmin`) != -1) {
currentTab = 'users';
}
+ if (location.href.indexOf(`${siteRoot}org/statistics-admin/`) != -1) {
+ currentTab = 'statistics-admin';
+ }
+ if (location.href.indexOf(`${siteRoot}org/deviceadmin/`) != -1) {
+ currentTab = 'deviceadmin';
+ }
if (location.href.indexOf(`${siteRoot}org/groupadmin`) != -1) {
currentTab = 'groupadmin';
}
@@ -68,7 +83,15 @@ class Org extends React.Component {
-
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/org-admin/org-users-users.js b/frontend/src/pages/org-admin/org-users-users.js
index 4677fb0765..0ba5c2fe97 100644
--- a/frontend/src/pages/org-admin/org-users-users.js
+++ b/frontend/src/pages/org-admin/org-users-users.js
@@ -4,6 +4,7 @@ import Nav from './org-users-nav';
import OrgUsersList from './org-users-list';
import MainPanelTopbar from './main-panel-topbar';
import ModalPortal from '../../components/modal-portal';
+import ImportOrgUsersDialog from '../../components/dialog/org-import-users-dialog';
import AddOrgUserDialog from '../../components/dialog/org-add-user-dialog';
import InviteUserDialog from '../../components/dialog/org-admin-invite-user-dialog';
import toaster from '../../components/toast';
@@ -72,6 +73,7 @@ class OrgUsers extends Component {
sortBy: '',
sortOrder: 'asc',
isShowAddOrgUserDialog: false,
+ isImportOrgUsersDialogOpen: false,
isInviteUserDialogOpen: false
};
}
@@ -110,6 +112,10 @@ class OrgUsers extends Component {
});
}
+ toggleImportOrgUsersDialog = () => {
+ this.setState({isImportOrgUsersDialogOpen: !this.state.isImportOrgUsersDialogOpen});
+ }
+
toggleAddOrgUser = () => {
this.setState({isShowAddOrgUserDialog: !this.state.isShowAddOrgUserDialog});
}
@@ -135,6 +141,30 @@ class OrgUsers extends Component {
});
}
+ importOrgUsers = (file) => {
+ toaster.notify(gettext('It may take some time, please wait.'));
+ seafileAPI.orgAdminImportUsersViaFile(orgID, file).then((res) => {
+ if (res.data.success.length) {
+ const users = res.data.success.map(item => {
+ if (item.institution == undefined) {
+ item.institution = '';
+ }
+ return new OrgUserInfo(item);
+ });
+ this.setState({
+ orgUsers: users.concat(this.state.orgUsers)
+ });
+ }
+ res.data.failed.map(item => {
+ const msg = `${item.email}: ${item.error_msg}`;
+ toaster.danger(msg);
+ });
+ }).catch((error) => {
+ let errMsg = Utils.getErrorMsg(error);
+ toaster.danger(errMsg);
+ });
+ }
+
addOrgUser = (email, name, password) => {
seafileAPI.orgAdminAddOrgUser(orgID, email, name, password).then(res => {
let userInfo = new OrgUserInfo(res.data);
@@ -198,12 +228,18 @@ class OrgUsers extends Component {
let topbarChildren;
topbarChildren = (
-
+
+
{invitationLink &&
}
+ {this.state.isImportOrgUsersDialogOpen &&
+
+
+
+ }
{this.state.isShowAddOrgUserDialog &&
diff --git a/frontend/src/pages/org-admin/side-panel.js b/frontend/src/pages/org-admin/side-panel.js
index 3e0943b7cf..8215371a3d 100644
--- a/frontend/src/pages/org-admin/side-panel.js
+++ b/frontend/src/pages/org-admin/side-panel.js
@@ -33,11 +33,23 @@ class SidePanel extends React.Component {
{gettext('Admin')}
-
- this.tabItemClick('orgmanage')} >
+ this.tabItemClick('info')} >
{gettext('Info')}
+ -
+ this.tabItemClick('statistics-admin')} >
+
+ {gettext('Statistic')}
+
+
+ -
+ this.tabItemClick('deviceadmin')} >
+
+ {gettext('Devices')}
+
+
-
this.tabItemClick('repoadmin')} >
diff --git a/frontend/src/pages/org-admin/statistic/picker.js b/frontend/src/pages/org-admin/statistic/picker.js
new file mode 100644
index 0000000000..d1836b4913
--- /dev/null
+++ b/frontend/src/pages/org-admin/statistic/picker.js
@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import moment from 'moment';
+import Calendar from '@seafile/seafile-calendar';
+import DatePicker from '@seafile/seafile-calendar/lib/Picker';
+import { translateCalendar } from '../../../utils/date-format-utils';
+
+import '@seafile/seafile-calendar/assets/index.css';
+
+const propsTypes = {
+ disabledDate: PropTypes.func.isRequired,
+ value: PropTypes.object,
+ onChange: PropTypes.func.isRequired,
+};
+
+const FORMAT = 'YYYY-MM-DD';
+
+class Picker extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.defaultCalendarValue = null;
+ }
+
+ componentDidMount() {
+ let lang = window.app.config.lang;
+ this.defaultCalendarValue = moment().locale(lang).clone();
+ }
+
+ render() {
+ const props = this.props;
+ const calendar = ();
+ return (
+
+ {
+ ({value}) => {
+ return (
+
+
+
+ );
+ }
+ }
+
+ );
+ }
+}
+
+Picker.propsTypes = propsTypes;
+
+export default Picker;
diff --git a/frontend/src/pages/org-admin/statistic/statistic-chart.js b/frontend/src/pages/org-admin/statistic/statistic-chart.js
new file mode 100644
index 0000000000..463e31da82
--- /dev/null
+++ b/frontend/src/pages/org-admin/statistic/statistic-chart.js
@@ -0,0 +1,123 @@
+import React from 'react';
+import { Line } from 'react-chartjs-2';
+import PropTypes from 'prop-types';
+import { Utils } from '../../../utils/utils';
+
+const propTypes = {
+ labels: PropTypes.array.isRequired,
+ filesData: PropTypes.array.isRequired,
+ suggestedMaxNumbers: PropTypes.number.isRequired,
+ isLegendStatus: PropTypes.bool.isRequired,
+ chartTitle: PropTypes.string.isRequired,
+ isTitleCallback: PropTypes.bool,
+ isTicksCallback: PropTypes.bool,
+};
+
+class StatisticChart extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ data: {},
+ opations: {}
+ };
+ }
+
+ componentDidMount() {
+ let { labels, filesData, isTitleCallback, isTicksCallback, suggestedMaxNumbers, isLegendStatus, chartTitle } = this.props;
+ let _this = this;
+ let data = {
+ labels: labels,
+ datasets: filesData
+ };
+ let options = {
+ title: {
+ display: true,
+ fontSize: 14,
+ text: chartTitle,
+ },
+ elements: {
+ line: {
+ fill: false,
+ tension: 0, // disable bezier curves, i.e, draw straight lines
+ borderWidth: 2
+ }
+ },
+ legend: {
+ display: isLegendStatus,
+ labels: {
+ usePointStyle: true
+ }
+ },
+ tooltips: {
+ callbacks: {
+ label: function(tooltipItem, data) {
+ if (isTitleCallback) {
+ return _this.titleCallback(tooltipItem, data);
+ }
+ return data.datasets[tooltipItem.datasetIndex].label + ': ' + tooltipItem.yLabel;
+ }
+ }
+ },
+ layout: {
+ padding: {
+ right: 100,
+ }
+ },
+ scales: {
+ yAxes: [{
+ ticks: {
+ beginAtZero: true,
+ suggestedMax: suggestedMaxNumbers,
+ callback: function(value, index, values) {
+ if (isTicksCallback) {
+ return _this.ticksCallback(value, index, values);
+ }
+ return value;
+ }
+ }
+ }],
+ xAxes: [{
+ ticks: {
+ maxTicksLimit: 20
+ }
+ }]
+ }
+ };
+ this.setState({
+ data: data,
+ options: options
+ });
+ }
+
+ componentWillReceiveProps(nextProps) {
+ let data = {
+ labels: nextProps.labels,
+ datasets: nextProps.filesData
+ };
+ this.setState({data: data});
+ }
+
+ titleCallback = (tooltipItem, data) => {
+ return data.datasets[tooltipItem.datasetIndex].label + ': ' + Utils.bytesToSize(tooltipItem.yLabel);
+ }
+
+ ticksCallback = (value, index, values) => {
+ return Utils.bytesToSize(value);
+ }
+
+ render() {
+
+ let { data, options } = this.state;
+ return (
+
+ );
+ }
+}
+
+StatisticChart.propTypes = propTypes;
+
+export default StatisticChart;
diff --git a/frontend/src/pages/org-admin/statistic/statistic-common-tool.js b/frontend/src/pages/org-admin/statistic/statistic-common-tool.js
new file mode 100644
index 0000000000..033acae1c8
--- /dev/null
+++ b/frontend/src/pages/org-admin/statistic/statistic-common-tool.js
@@ -0,0 +1,138 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { Button } from 'reactstrap';
+import moment from 'moment';
+import { gettext } from '../../../utils/constants';
+import Picker from './picker';
+
+const propTypes = {
+ getActiviesFiles: PropTypes.func.isRequired,
+ children: PropTypes.object,
+};
+
+class StatisticCommonTool extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ statisticType: 'oneWeek',
+ startValue: null,
+ endValue: null,
+ };
+ }
+
+ componentDidMount() {
+ let today = moment().format('YYYY-MM-DD 00:00:00');
+ let endTime = today;
+ let startTime = moment().subtract(6,'d').format('YYYY-MM-DD 00:00:00');
+ let group_by = 'day';
+ this.props.getActiviesFiles(startTime, endTime, group_by);
+ }
+
+ changeActive = (statisticTypeName) => {
+ let { statisticType } = this.state;
+ if (statisticType === statisticTypeName) {
+ return;
+ }
+ let today = moment().format('YYYY-MM-DD 00:00:00');
+ let endTime = today;
+ let startTime;
+ switch(statisticTypeName) {
+ case 'oneWeek' :
+ startTime = moment().subtract(6,'d').format('YYYY-MM-DD 00:00:00');
+ break;
+ case 'oneMonth' :
+ startTime = moment().subtract(29,'d').format('YYYY-MM-DD 00:00:00');
+ break;
+ case 'oneYear' :
+ startTime = moment().subtract(364,'d').format('YYYY-MM-DD 00:00:00');
+ break;
+ }
+ this.setState({
+ statisticType: statisticTypeName,
+ });
+ let group_by = 'day';
+ this.props.getActiviesFiles(startTime, endTime, group_by);
+ }
+
+ disabledStartDate = (startValue) => {
+ if (!startValue) {
+ return false;
+ }
+ let today = moment().format();
+
+ const endValue = this.state.endValue;
+ if (!endValue) {
+ let startTime = moment(startValue).format();
+ return today < startTime;
+ }
+ return endValue.isBefore(startValue) || moment(startValue).format() > today;
+ }
+
+ disabledEndDate = (endValue) => {
+ if (!endValue) {
+ return false;
+ }
+ let today = moment().format();
+ const startValue = this.state.startValue;
+ if (!startValue) {
+ let endTime = moment(endValue).format();
+ return today < endTime;
+ }
+ return endValue.isBefore(startValue) || moment(endValue).format() > today;
+ }
+
+ onChange = (field, value) => {
+ this.setState({
+ [field]: value,
+ });
+ }
+
+ onSubmit = () => {
+ let { startValue, endValue } = this.state;
+ if(!startValue || !endValue) {
+ return;
+ }
+ this.setState({
+ statisticType: 'itemButton',
+ });
+ let startTime = moment(startValue).format('YYYY-MM-DD 00:00:00');
+ let endTime = moment(endValue).format('YYYY-MM-DD 00:00:00');
+ let group_by = 'day';
+ this.props.getActiviesFiles(startTime, endTime, group_by);
+ }
+
+ render() {
+ let { statisticType, endValue, startValue } = this.state;
+ return(
+
+ {this.props.children}
+
+
+
{gettext('7 Days')}
+
{gettext('30 Days')}
+
{gettext('1 Year')}
+
+
+
+
-
+
+
+
+
+
+ );
+ }
+}
+
+StatisticCommonTool.propTypes = propTypes;
+
+export default StatisticCommonTool;
diff --git a/frontend/src/pages/org-admin/statistic/statistic-file.js b/frontend/src/pages/org-admin/statistic/statistic-file.js
new file mode 100644
index 0000000000..75708915a1
--- /dev/null
+++ b/frontend/src/pages/org-admin/statistic/statistic-file.js
@@ -0,0 +1,104 @@
+import React, { Fragment } from 'react';
+import moment from 'moment';
+import MainPanelTopbar from '../main-panel-topbar';
+import StatisticNav from './statistic-nav';
+import StatisticCommonTool from './statistic-common-tool';
+import { seafileAPI } from '../../../utils/seafile-api';
+import StatisticChart from './statistic-chart';
+import Loading from '../../../components/loading';
+import { gettext, orgID } from '../../../utils/constants';
+import { Utils } from '../../../utils/utils';
+import toaster from '../../../components/toast';
+
+import '../../../css/system-stat.css';
+
+class OrgStatisticFile extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ filesData: [],
+ labels: [],
+ isLoading: true
+ };
+ }
+
+ getActiviesFiles = (startTime, endTime, groupBy) => {
+ let { filesData } = this.state;
+
+ seafileAPI.orgAdminStatisticFiles(orgID, startTime, endTime, groupBy).then((res) => {
+ let labels = [],
+ added = [],
+ deleted = [],
+ visited = [],
+ modified = [];
+ let data = res.data;
+ if (Array.isArray(data)) {
+ data.forEach(item => {
+ labels.push(moment(item.datetime).format('YYYY-MM-DD'));
+ added.push(item.added);
+ deleted.push(item.deleted);
+ modified.push(item.modified);
+ visited.push(item.visited);
+ });
+ let addedData = {
+ label: gettext('Added'),
+ data: added,
+ borderColor: '#57cd6b',
+ backgroundColor: '#57cd6b'};
+ let visitedData = {
+ label: gettext('Visited'),
+ data: visited,
+ borderColor: '#fd913a',
+ backgroundColor: '#fd913a'};
+ let modifiedData = {
+ label: gettext('Modified'),
+ data: modified,
+ borderColor: '#72c3fc',
+ backgroundColor: '#72c3fc'};
+ let deletedData = {
+ label: gettext('Deleted'),
+ data: deleted,
+ borderColor: '#f75356',
+ backgroundColor: '#f75356'};
+ filesData = [visitedData, addedData, modifiedData, deletedData];
+ }
+ this.setState({
+ filesData: filesData,
+ labels: labels,
+ isLoading: false
+ });
+ }).catch(err => {
+ let errMessage = Utils.getErrorMsg(err);
+ toaster.danger(errMessage);
+ });
+ }
+
+ render() {
+ let { labels, filesData, isLoading } = this.state;
+
+ return(
+
+
+
+
+
+
+ {isLoading && }
+ {!isLoading && labels.length > 0 &&
+
+ }
+
+
+
+ );
+ }
+}
+
+export default OrgStatisticFile;
diff --git a/frontend/src/pages/org-admin/statistic/statistic-nav.js b/frontend/src/pages/org-admin/statistic/statistic-nav.js
new file mode 100644
index 0000000000..eb5f20c488
--- /dev/null
+++ b/frontend/src/pages/org-admin/statistic/statistic-nav.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Link } from '@reach/router';
+import { siteRoot, gettext } from '../../../utils/constants';
+
+const propTypes = {
+ currentItem: PropTypes.string.isRequired
+};
+
+class Nav extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.navItems = [
+ {name: 'fileStatistic', urlPart: 'statistics-admin/file', text: gettext('File')},
+ {name: 'storageStatistic', urlPart: 'statistics-admin/total-storage', text: gettext('Storage')},
+ {name: 'usersStatistic', urlPart: 'statistics-admin/active-users', text: gettext('Users')},
+ {name: 'trafficStatistic', urlPart: 'statistics-admin/traffic', text: gettext('Traffic')},
+ {name: 'reportsStatistic', urlPart: 'statistics-admin/reports', text: gettext('Reports')},
+ ];
+ }
+
+ render() {
+ const { currentItem } = this.props;
+ return (
+
+
+ {this.navItems.map((item, index) => {
+ return (
+ -
+ {item.text}
+
+ );
+ })}
+
+
+ );
+ }
+}
+
+Nav.propTypes = propTypes;
+
+export default Nav;
diff --git a/frontend/src/pages/org-admin/statistic/statistic-reports.js b/frontend/src/pages/org-admin/statistic/statistic-reports.js
new file mode 100644
index 0000000000..11f2c51897
--- /dev/null
+++ b/frontend/src/pages/org-admin/statistic/statistic-reports.js
@@ -0,0 +1,88 @@
+import React, { Fragment } from 'react';
+import MainPanelTopbar from '../main-panel-topbar';
+import moment from 'moment';
+import StatisticNav from './statistic-nav';
+import { Button, Input } from 'reactstrap';
+import { siteRoot, gettext, orgID } from '../../../utils/constants';
+
+class OrgStatisticReports extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ month: moment().format('YYYYMM'),
+ errorMessage: ''
+ };
+ }
+
+ handleChange = (e) => {
+ let month = e.target.value;
+ this.setState({
+ month: month
+ });
+ }
+
+ onGenerateReports = (type) => {
+ let url = siteRoot + 'api/v2.1/org/' + orgID + '/admin/statistics/';
+ let { month } = this.state;
+ if (!month) {
+ let errorMessage = gettext('It is required.');
+ this.setState({
+ errorMessage: errorMessage
+ });
+ return;
+ }
+ if (type === 'month') {
+ let pattern = /^([012]\d{3})(0[1-9]|1[012])$/;
+ if (!pattern.test(month)) {
+ let errorMessage = gettext('Invalid month, should be yyyymm.');
+ this.setState({
+ errorMessage: errorMessage
+ });
+ return;
+ }
+ }
+ switch(type) {
+ case 'month':
+ url += 'user-traffic/excel/?month=' + month;
+ break;
+ case 'storage':
+ url += 'user-storage/excel/?';
+ break;
+ }
+ this.setState({
+ errorMessage: ''
+ });
+ window.location.href = url;
+ }
+
+ render() {
+
+ let { errorMessage } = this.state;
+ return(
+
+
+
+
+
+
+
{gettext('Monthly User Traffic')}
+
+ {gettext('Month:')}
+
+
+
+ {errorMessage &&
{errorMessage}
}
+
+
+
{gettext('User Storage')}
+
+
+
+
+
+ );
+ }
+}
+
+export default OrgStatisticReports;
diff --git a/frontend/src/pages/org-admin/statistic/statistic-storage.js b/frontend/src/pages/org-admin/statistic/statistic-storage.js
new file mode 100644
index 0000000000..63a499e151
--- /dev/null
+++ b/frontend/src/pages/org-admin/statistic/statistic-storage.js
@@ -0,0 +1,81 @@
+import React, { Fragment } from 'react';
+import moment from 'moment';
+import MainPanelTopbar from '../main-panel-topbar';
+import StatisticNav from './statistic-nav';
+import StatisticCommonTool from './statistic-common-tool';
+import { seafileAPI } from '../../../utils/seafile-api';
+import StatisticChart from './statistic-chart';
+import Loading from '../../../components/loading';
+import { gettext, orgID } from '../../../utils/constants';
+import { Utils } from '../../../utils/utils';
+import toaster from '../../../components/toast';
+
+class OrgStatisticStorage extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ filesData: [],
+ labels: [],
+ isLoading: true
+ };
+ }
+
+ getActiviesFiles = (startTime, endTime, groupBy) => {
+ let { filesData } = this.state;
+ seafileAPI.orgAdminStatisticStorages(orgID, startTime, endTime, groupBy).then((res) => {
+ let labels = [],
+ totalStorage = [];
+ let data = res.data;
+ if (Array.isArray(data)) {
+ data.forEach(item => {
+ labels.push(moment(item.datetime).format('YYYY-MM-DD'));
+ totalStorage.push(item.total_storage);
+ });
+ let total_storage = {
+ label: gettext('Total Storage'),
+ data: totalStorage,
+ borderColor: '#fd913a',
+ backgroundColor: '#fd913a'};
+ filesData = [total_storage];
+ }
+ this.setState({
+ filesData: filesData,
+ labels: labels,
+ isLoading: false
+ });
+ }).catch(err => {
+ let errMessage = Utils.getErrorMsg(err);
+ toaster.danger(errMessage);
+ });
+ }
+
+ render() {
+ let { labels, filesData, isLoading } = this.state;
+ return(
+
+
+
+
+
+
+ {isLoading && }
+ {!isLoading && labels.length > 0 &&
+
+ }
+
+
+
+ );
+ }
+}
+
+export default OrgStatisticStorage;
diff --git a/frontend/src/pages/org-admin/statistic/statistic-traffic-users.js b/frontend/src/pages/org-admin/statistic/statistic-traffic-users.js
new file mode 100644
index 0000000000..6282c64549
--- /dev/null
+++ b/frontend/src/pages/org-admin/statistic/statistic-traffic-users.js
@@ -0,0 +1,158 @@
+import React, { Fragment } from 'react';
+import { Input } from 'reactstrap';
+import TrafficTable from './traffic-table';
+import TrafficTableBody from './traffic-table-body';
+import { seafileAPI } from '../../../utils/seafile-api';
+import Paginator from '../../../components/paginator';
+import moment from 'moment';
+import Loading from '../../../components/loading';
+import { gettext, orgID } from '../../../utils/constants';
+import { Utils } from '../../../utils/utils';
+import toaster from '../../../components/toast';
+
+class UsersTraffic extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ userTrafficList: [],
+ hasNextPage: false,
+ perPage: 25,
+ currentPage: 1,
+ month: moment().format('YYYYMM'),
+ isLoading: false,
+ errorMessage: '',
+ sortBy: 'link_file_download',
+ sortOrder: 'desc'
+ };
+ this.initPage = 1;
+ this.initMonth = moment().format('YYYYMM');
+ }
+
+ componentDidMount() {
+ let urlParams = (new URL(window.location)).searchParams;
+ const { currentPage, perPage } = this.state;
+ this.setState({
+ perPage: parseInt(urlParams.get('per_page') || perPage),
+ currentPage: parseInt(urlParams.get('page') || currentPage)
+ }, () => {
+ this.getTrafficList(this.initMonth, this.state.currentPage);
+ });
+ }
+
+ getPreviousPage = () => {
+ this.getTrafficList(this.state.month, this.state.currentPage - 1);
+ }
+
+ getNextPage = () => {
+ this.getTrafficList(this.state.month, this.state.currentPage + 1);
+ }
+
+ handleChange = (e) => {
+ let month = e.target.value;
+ this.setState({
+ month: month
+ });
+ }
+
+ handleKeyPress = (e) => {
+ let { month } = this.state;
+ if (e.key === 'Enter') {
+ let pattern = /^([012]\d{3})(0[1-9]|1[012])$/;
+ if (!pattern.test(month)) {
+ let errorMessage = gettext('Invalid month, should be yyyymm.');
+ this.setState({
+ errorMessage: errorMessage
+ });
+ return;
+ }
+ this.getTrafficList(month, this.initPage);
+ e.target.blur();
+ e.preventDefault();
+ }
+ }
+
+ getTrafficList = (month, page) => {
+ const { perPage, sortBy, sortOrder } = this.state;
+ const orderBy = sortOrder == 'asc' ? sortBy : `${sortBy}_${sortOrder}`;
+ this.setState({
+ isLoading: true,
+ errorMessage: ''
+ });
+ seafileAPI.orgAdminListUserTraffic(orgID, month, page, perPage, orderBy).then(res => {
+ let userTrafficList = res.data.user_monthly_traffic_list.slice(0);
+ this.setState({
+ month: month,
+ currentPage: page,
+ userTrafficList: userTrafficList,
+ hasNextPage: res.data.has_next_page,
+ isLoading: false
+ });
+ }).catch(err => {
+ let errMessage = Utils.getErrorMsg(err);
+ toaster.danger(errMessage);
+ });
+ }
+
+ sortItems = (sortBy) => {
+ this.setState({
+ sortBy: sortBy,
+ sortOrder: this.state.sortOrder == 'asc' ? 'desc' : 'asc'
+ }, () => {
+ const { month, currentPage } = this.state;
+ this.getTrafficList(month, currentPage);
+ });
+ }
+
+ resetPerPage = (newPerPage) => {
+ this.setState({
+ perPage: newPerPage,
+ }, () => this.getTrafficList(this.initMonth, this.initPage));
+ }
+
+ render() {
+ const {
+ isLoading, errorMessage, userTrafficList,
+ currentPage, hasNextPage, perPage,
+ sortBy, sortOrder
+ } = this.state;
+ return (
+
+
+
{gettext('Month:')}
+
+ {errorMessage &&
{errorMessage}
}
+
+ {isLoading && }
+ {!isLoading &&
+
+ {userTrafficList.length > 0 && userTrafficList.map((item, index) => {
+ return(
+
+ );
+ })}
+
+ }
+
+
+ );
+ }
+}
+
+export default UsersTraffic;
diff --git a/frontend/src/pages/org-admin/statistic/statistic-traffic.js b/frontend/src/pages/org-admin/statistic/statistic-traffic.js
new file mode 100644
index 0000000000..31a89b0844
--- /dev/null
+++ b/frontend/src/pages/org-admin/statistic/statistic-traffic.js
@@ -0,0 +1,213 @@
+import React, { Fragment } from 'react';
+import moment from 'moment';
+import { gettext, orgID } from '../../../utils/constants';
+import { seafileAPI } from '../../../utils/seafile-api';
+import MainPanelTopbar from '../main-panel-topbar';
+import StatisticNav from './statistic-nav';
+import StatisticCommonTool from './statistic-common-tool';
+import Loading from '../../../components/loading';
+import UsersTraffic from './statistic-traffic-users';
+import StatisticChart from './statistic-chart';
+import { Utils } from '../../../utils/utils';
+import toaster from '../../../components/toast';
+
+class OrgStatisticTraffic extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ filesData: [],
+ linkData: [],
+ syncData: [],
+ webData: [],
+ labels: [],
+ isLoading: true,
+ tabActive: 'system'
+ };
+ }
+
+ changeTabActive = activeName => {
+ this.setState({tabActive: activeName});
+ }
+
+ getActiviesFiles = (startTime, endTime, groupBy) => {
+ seafileAPI.orgAdminStatisticSystemTraffic(orgID, startTime, endTime, groupBy).then((res) => {
+ let labels = [];
+ let total_upload = [],
+ total_download = [],
+ link_upload = [],
+ link_download = [],
+ sync_upload = [],
+ sync_download = [],
+ web_upload = [],
+ web_download = [];
+ let data = res.data;
+ if (Array.isArray(data)) {
+ data.forEach(item => {
+ labels.push(moment(item.datetime).format('YYYY-MM-DD'));
+ link_upload.push(item['link-file-upload']);
+ link_download.push(item['link-file-download']);
+ sync_upload.push(item['sync-file-upload']);
+ sync_download.push(item['sync-file-download']);
+ web_upload.push(item['web-file-upload']);
+ web_download.push(item['web-file-download']);
+ total_upload.push(item['link-file-upload'] + item['sync-file-upload'] + item['web-file-upload']);
+ total_download.push(item['link-file-download'] + item['sync-file-download'] + item['web-file-download']);
+ });
+ let linkUpload = {
+ label: gettext('Upload'),
+ data: link_upload,
+ borderColor: '#fd913a',
+ backgroundColor: '#fd913a'};
+ let linkDownload = {
+ label: gettext('Download'),
+ data: link_download,
+ borderColor: '#57cd6b',
+ backgroundColor: '#57cd6b'};
+ let syncUpload = {
+ label: gettext('Upload'),
+ data: sync_upload,
+ borderColor: '#fd913a',
+ backgroundColor: '#fd913a'};
+ let syncDownload = {
+ label: gettext('Download'),
+ data: sync_download,
+ borderColor: '#57cd6b',
+ backgroundColor: '#57cd6b'};
+ let webUpload = {
+ label: gettext('Upload'),
+ data: web_upload,
+ borderColor: '#fd913a',
+ backgroundColor: '#fd913a'};
+ let webDownload = {
+ label: gettext('Download'),
+ data: web_download,
+ borderColor: '#57cd6b',
+ backgroundColor: '#57cd6b'};
+ let totalUpload = {
+ label: gettext('Upload'),
+ data: total_upload,
+ borderColor: '#fd913a',
+ backgroundColor: '#fd913a'};
+ let totalDownload = {
+ label: gettext('Download'),
+ data: total_download,
+ borderColor: '#57cd6b',
+ backgroundColor: '#57cd6b'};
+ let linkData = [linkUpload, linkDownload];
+ let syncData = [syncUpload, syncDownload];
+ let webData = [webUpload, webDownload];
+ let filesData = [totalUpload, totalDownload];
+ this.setState({
+ linkData: linkData,
+ syncData: syncData,
+ webData: webData,
+ filesData: filesData,
+ labels: labels,
+ isLoading: false
+ });
+ }
+ }).catch(err => {
+ let errMessage = Utils.getErrorMsg(err);
+ toaster.danger(errMessage);
+ });
+ }
+
+ renderCommonTool = () => {
+ let { tabActive } = this.state;
+ if (tabActive === 'system') {
+ return (
+
+
+
{gettext('System')}
+
{gettext('Users')}
+
+
+ );
+ }
+ return (
+
+
{gettext('System')}
+
{gettext('Users')}
+
+ );
+ }
+
+ render() {
+ let { labels, filesData, linkData, syncData, webData, isLoading, tabActive } = this.state;
+
+ return (
+
+
+
+
+
+ {this.renderCommonTool()}
+ {isLoading &&
}
+ {!isLoading && tabActive === 'system' &&
+
+
+ {labels.length > 0 &&
+
+ }
+
+
+ {labels.length > 0 &&
+
+ }
+
+
+ {labels.length > 0 &&
+
+ }
+
+
+ {labels.length > 0 &&
+
+ }
+
+
+ }
+ {!isLoading && tabActive === 'user' &&
+
+ }
+
+
+
+ );
+ }
+}
+
+export default OrgStatisticTraffic;
diff --git a/frontend/src/pages/org-admin/statistic/statistic-users.js b/frontend/src/pages/org-admin/statistic/statistic-users.js
new file mode 100644
index 0000000000..e9421bd992
--- /dev/null
+++ b/frontend/src/pages/org-admin/statistic/statistic-users.js
@@ -0,0 +1,79 @@
+import React, { Fragment } from 'react';
+import moment from 'moment';
+import { gettext, orgID } from '../../../utils/constants';
+import MainPanelTopbar from '../main-panel-topbar';
+import StatisticNav from './statistic-nav';
+import StatisticCommonTool from './statistic-common-tool';
+import { seafileAPI } from '../../../utils/seafile-api';
+import StatisticChart from './statistic-chart';
+import Loading from '../../../components/loading';
+import { Utils } from '../../../utils/utils';
+import toaster from '../../../components/toast';
+
+class OrgStatisticUsers extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ filesData: [],
+ labels: [],
+ isLoading: true,
+ };
+ }
+
+ getActiviesFiles = (startTime, endTime, groupBy) => {
+ let { filesData } = this.state;
+ seafileAPI.orgAdminStatisticActiveUsers(orgID, startTime, endTime, groupBy).then((res) => {
+ let labels = [],
+ count = [];
+ let data = res.data;
+ if (Array.isArray(data)) {
+ data.forEach(item => {
+ labels.push(moment(item.datetime).format('YYYY-MM-DD'));
+ count.push(item.count);
+ });
+ let userCount = {
+ label: gettext('Active Users'),
+ data: count,
+ borderColor: '#fd913a',
+ backgroundColor: '#fd913a'};
+ filesData = [userCount];
+ }
+ this.setState({
+ filesData: filesData,
+ labels: labels,
+ isLoading: false
+ });
+ }).catch(err => {
+ let errMessage = Utils.getErrorMsg(err);
+ toaster.danger(errMessage);
+ });
+ }
+
+ render() {
+ let { labels, filesData, isLoading } = this.state;
+ return (
+
+
+
+
+
+
+ {isLoading && }
+ {!isLoading && labels.length > 0 &&
+
+ }
+
+
+
+ );
+ }
+}
+
+export default OrgStatisticUsers;
diff --git a/frontend/src/pages/org-admin/statistic/traffic-table-body.js b/frontend/src/pages/org-admin/statistic/traffic-table-body.js
new file mode 100644
index 0000000000..67dd62155d
--- /dev/null
+++ b/frontend/src/pages/org-admin/statistic/traffic-table-body.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Utils } from '../../../utils/utils';
+import { siteRoot } from '../../../utils/constants';
+
+const propTypes = {
+ type: PropTypes.string.isRequired,
+ userTrafficItem: PropTypes.object.isRequired,
+};
+
+class TrafficTableBody extends React.Component {
+
+ trafficName = () => {
+ let { userTrafficItem, type } = this.props;
+ switch(type) {
+ case 'user':
+ if (userTrafficItem.name) {
+ return (
+ {userTrafficItem.name}
+ );
+ }
+ return({'--'});
+ case 'org':
+ return({userTrafficItem.org_name});
+ }
+ }
+
+ render() {
+ let { userTrafficItem } = this.props;
+
+ let syncUploadSize = Utils.bytesToSize(userTrafficItem.sync_file_upload);
+ let syncDownloadSize = Utils.bytesToSize(userTrafficItem.sync_file_download);
+ let webUploadSize = Utils.bytesToSize(userTrafficItem.web_file_upload);
+ let webDownloadSize = Utils.bytesToSize(userTrafficItem.web_file_download);
+ let linkUploadSize = Utils.bytesToSize(userTrafficItem.link_file_upload);
+ let linkDownloadSize = Utils.bytesToSize(userTrafficItem.link_file_download);
+
+ return(
+
+ {this.trafficName()} |
+ {syncUploadSize} |
+ {syncDownloadSize} |
+ {webUploadSize} |
+ {webDownloadSize} |
+ {linkUploadSize} |
+ {linkDownloadSize} |
+
+ );
+ }
+}
+
+TrafficTableBody.propTypes = propTypes;
+
+export default TrafficTableBody;
diff --git a/frontend/src/pages/org-admin/statistic/traffic-table.js b/frontend/src/pages/org-admin/statistic/traffic-table.js
new file mode 100644
index 0000000000..34bcb944a1
--- /dev/null
+++ b/frontend/src/pages/org-admin/statistic/traffic-table.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { gettext } from '../../../utils/constants';
+
+const propTypes = {
+ type: PropTypes.string.isRequired,
+ sortBy: PropTypes.string.isRequired,
+ sortOrder: PropTypes.string.isRequired,
+ sortItems: PropTypes.func.isRequired,
+ children: PropTypes.oneOfType([PropTypes.bool, PropTypes.array]),
+};
+
+class TrafficTable extends React.Component {
+
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ const { type, sortBy, sortOrder } = this.props;
+ const sortIcon = sortOrder == 'asc' ? : ;
+
+ return (
+
+
+
+ {type == 'user' ? gettext('User') : gettext('Organization')} |
+ {gettext('Sync Upload')} {sortBy === 'sync_file_upload' && sortIcon} |
+ {gettext('Sync Download')} {sortBy === 'sync_file_download' && sortIcon} |
+ {gettext('Web Upload')} {sortBy === 'web_file_upload' && sortIcon} |
+ {gettext('Web Download')} {sortBy === 'web_file_download' && sortIcon} |
+ {gettext('Share link upload')} {sortBy === 'link_file_upload' && sortIcon} |
+ {gettext('Share link download')} {sortBy === 'link_file_download' && sortIcon} |
+
+
+
+ {this.props.children}
+
+
+ );
+ }
+}
+
+TrafficTable.propTypes = propTypes;
+
+export default TrafficTable;
diff --git a/frontend/src/pages/org-admin/user-link.js b/frontend/src/pages/org-admin/user-link.js
new file mode 100644
index 0000000000..f4bb4be3bc
--- /dev/null
+++ b/frontend/src/pages/org-admin/user-link.js
@@ -0,0 +1,20 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { Link } from '@reach/router';
+import { siteRoot, orgID } from '../../utils/constants';
+
+const propTypes = {
+ email: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired
+};
+
+class UserLink extends Component {
+
+ render() {
+ return {this.props.name};
+ }
+}
+
+UserLink.propTypes = propTypes;
+
+export default UserLink;
diff --git a/seahub/organizations/api/admin/devices.py b/seahub/organizations/api/admin/devices.py
new file mode 100644
index 0000000000..05db9dcace
--- /dev/null
+++ b/seahub/organizations/api/admin/devices.py
@@ -0,0 +1,220 @@
+# Copyright (c) 2012-2016 Seafile Ltd.
+import logging
+
+from rest_framework.authentication import SessionAuthentication
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from rest_framework import status
+
+from seaserv import seafile_api, ccnet_api
+from pysearpc import SearpcError
+
+from seahub.utils.devices import do_unlink_device
+from seahub.utils.timeutils import datetime_to_isoformat_timestr, \
+ timestamp_to_isoformat_timestr
+
+from seahub.api2.permissions import IsProVersion, IsOrgAdminUser
+from seahub.api2.authentication import TokenAuthentication
+from seahub.api2.throttling import UserRateThrottle
+from seahub.api2.utils import api_error
+from seahub.api2.models import TokenV2, DESKTOP_PLATFORMS, MOBILE_PLATFORMS
+from seahub.base.templatetags.seahub_tags import email2nickname
+
+logger = logging.getLogger(__name__)
+
+
+class OrgAdminDevices(APIView):
+
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ throttle_classes = (UserRateThrottle,)
+ permission_classes = (IsProVersion, IsOrgAdminUser)
+
+ def get(self, request, org_id):
+
+ org_id = int(org_id)
+ org = ccnet_api.get_org_by_id(org_id)
+ if not org:
+ error_msg = 'Organization %s not found.' % org_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ try:
+ current_page = int(request.GET.get('page', '1'))
+ per_page = int(request.GET.get('per_page', '50'))
+ except ValueError:
+ current_page = 1
+ per_page = 50
+
+ start = (current_page - 1) * per_page
+ end = current_page * per_page + 1
+
+ platform = request.GET.get('platform', None)
+ org_users = ccnet_api.get_org_users_by_url_prefix(org.url_prefix, -1, -1)
+ org_user_emails = [user.email for user in org_users]
+
+ devices = TokenV2.objects.filter(wiped_at=None)
+
+ if platform == 'desktop':
+ devices = devices.filter(platform__in=DESKTOP_PLATFORMS) \
+ .filter(user__in=org_user_emails) \
+ .order_by('-last_accessed')[start: end]
+
+ elif platform == 'mobile':
+ devices = devices.filter(platform__in=MOBILE_PLATFORMS) \
+ .filter(user__in=org_user_emails) \
+ .order_by('-last_accessed')[start: end]
+ else:
+ devices = devices.order_by('-last_accessed') \
+ .filter(user__in=org_user_emails)[start: end]
+
+ if len(devices) == end - start:
+ devices = devices[:per_page]
+ has_next_page = True
+ else:
+ has_next_page = False
+
+ return_results = []
+ for device in devices:
+ result = {}
+ result['client_version'] = device.client_version
+ result['device_id'] = device.device_id
+ result['device_name'] = device.device_name
+ result['last_accessed'] = datetime_to_isoformat_timestr(device.last_accessed)
+ result['last_login_ip'] = device.last_login_ip
+ result['user'] = device.user
+ result['user_name'] = email2nickname(device.user)
+ result['platform'] = device.platform
+
+ result['is_desktop_client'] = False
+ if result['platform'] in DESKTOP_PLATFORMS:
+ result['is_desktop_client'] = True
+
+ return_results.append(result)
+
+ page_info = {
+ 'has_next_page': has_next_page,
+ 'current_page': current_page
+ }
+ return Response({"page_info": page_info, "devices": return_results})
+
+ def delete(self, request, org_id):
+
+ org_id = int(org_id)
+ org = ccnet_api.get_org_by_id(org_id)
+ if not org:
+ error_msg = 'Organization %s not found.' % org_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ platform = request.data.get('platform', '')
+ device_id = request.data.get('device_id', '')
+ remote_wipe = request.data.get('wipe_device', '')
+ user = request.data.get('user', '')
+
+ if not platform:
+ error_msg = 'platform invalid.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ if not device_id:
+ error_msg = 'device_id invalid.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ if not user:
+ error_msg = 'user invalid.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ remote_wipe = True if remote_wipe == 'true' else False
+
+ try:
+ do_unlink_device(user, platform, device_id, remote_wipe=remote_wipe)
+ except SearpcError as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response({'success': True})
+
+
+class OrgAdminDevicesErrors(APIView):
+
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ throttle_classes = (UserRateThrottle, )
+ permission_classes = (IsProVersion, IsOrgAdminUser)
+
+ def get(self, request, org_id):
+
+ org_id = int(org_id)
+ org = ccnet_api.get_org_by_id(org_id)
+ if not org:
+ error_msg = 'Organization %s not found.' % org_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ try:
+ current_page = int(request.GET.get('page', '1'))
+ per_page = int(request.GET.get('per_page', '100'))
+ except ValueError:
+ current_page = 1
+ per_page = 100
+
+ start = (current_page - 1) * per_page
+ limit = per_page + 1
+
+ return_results = []
+ try:
+ device_errors = seafile_api.list_org_repo_sync_errors(org_id, start, limit)
+ except SearpcError as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ if len(device_errors) > per_page:
+ device_errors = device_errors[:per_page]
+ has_next_page = True
+ else:
+ has_next_page = False
+
+ for error in device_errors:
+ result = {}
+ result['email'] = error.email if error.email else ''
+ result['name'] = email2nickname(error.email)
+ result['device_ip'] = error.peer_ip if error.peer_ip else ''
+ result['repo_name'] = error.repo_name if error.repo_name else ''
+ result['repo_id'] = error.repo_id if error.repo_id else ''
+ result['error_msg'] = error.error_con if error.error_con else ''
+
+ tokens = TokenV2.objects.filter(device_id=error.peer_id)
+ if tokens:
+ result['device_name'] = tokens[0].device_name
+ result['client_version'] = tokens[0].client_version
+ else:
+ result['device_name'] = ''
+ result['client_version'] = ''
+
+ if error.error_time:
+ result['error_time'] = timestamp_to_isoformat_timestr(error.error_time)
+ else:
+ result['error_time'] = ''
+
+ return_results.append(result)
+
+ page_info = {
+ 'has_next_page': has_next_page,
+ 'current_page': current_page
+ }
+
+ return Response({"page_info": page_info, "device_errors": return_results})
+
+ def delete(self, request, org_id):
+
+ org_id = int(org_id)
+ org = ccnet_api.get_org_by_id(org_id)
+ if not org:
+ error_msg = 'Organization %s not found.' % org_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ try:
+ seafile_api.clear_repo_sync_errors()
+ except SearpcError as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response({'success': True})
diff --git a/seahub/organizations/api/admin/statistics.py b/seahub/organizations/api/admin/statistics.py
new file mode 100644
index 0000000000..e93e5d412c
--- /dev/null
+++ b/seahub/organizations/api/admin/statistics.py
@@ -0,0 +1,411 @@
+# Copyright (c) 2012-2016 Seafile Ltd.
+import datetime
+import pytz
+import logging
+
+from rest_framework.authentication import SessionAuthentication
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from rest_framework import status
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from django.http import HttpResponse
+
+from seaserv import ccnet_api
+
+from seahub.utils import get_org_file_ops_stats_by_day, \
+ get_org_total_storage_stats_by_day, get_org_user_activity_stats_by_day, \
+ get_org_traffic_by_day, is_pro_version, EVENTS_ENABLED, \
+ seafevents_api
+from seahub.utils.timeutils import datetime_to_isoformat_timestr
+from seahub.utils.ms_excel import write_xls
+from seahub.utils.file_size import byte_to_mb
+from seahub.views.sysadmin import _populate_user_quota_usage
+from seahub.base.templatetags.seahub_tags import email2nickname, \
+ email2contact_email
+
+from seahub.api2.permissions import IsProVersion, IsOrgAdminUser
+from seahub.api2.authentication import TokenAuthentication
+from seahub.api2.throttling import UserRateThrottle
+from seahub.api2.utils import api_error
+
+logger = logging.getLogger(__name__)
+
+
+def get_time_offset():
+ timezone_name = timezone.get_current_timezone_name()
+ offset = pytz.timezone(timezone_name).localize(datetime.datetime.now()).strftime('%z')
+ return offset[:3] + ':' + offset[3:]
+
+
+def get_init_data(start_time, end_time, init_data=0):
+ res = {}
+ start_time = start_time.replace(hour=0).replace(minute=0).replace(second=0)
+ end_time = end_time.replace(hour=0).replace(minute=0).replace(second=0)
+ time_delta = end_time - start_time
+ date_length = time_delta.days + 1
+ for offset in range(date_length):
+ offset = offset * 24
+ dt = start_time + datetime.timedelta(hours=offset)
+ if isinstance(init_data, dict):
+ res[dt] = init_data.copy()
+ else:
+ res[dt] = init_data
+ return res
+
+
+def check_parameter(func):
+
+ def _decorated(view, request, org_id, *args, **kwargs):
+
+ if not is_pro_version() or not EVENTS_ENABLED:
+ return api_error(status.HTTP_404_NOT_FOUND, 'Events not enabled.')
+
+ start_time = request.GET.get("start", "")
+ end_time = request.GET.get("end", "")
+
+ if not start_time:
+ error_msg = "Start time can not be empty"
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ if not end_time:
+ error_msg = "End time can not be empty"
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ try:
+ start_time = datetime.datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S")
+ except Exception:
+ error_msg = "Start time %s invalid" % start_time
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ try:
+ end_time = datetime.datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S")
+ except Exception:
+ error_msg = "End time %s invalid" % end_time
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ return func(view, request, org_id, start_time, end_time, *args, **kwargs)
+
+ return _decorated
+
+
+class OrgFileOperationsView(APIView):
+
+ """
+ Get file operations statistics.
+ Permission checking:
+ 1. only org admin can perform this action.
+ """
+
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ throttle_classes = (UserRateThrottle,)
+ permission_classes = (IsProVersion, IsOrgAdminUser)
+
+ @check_parameter
+ def get(self, request, org_id, start_time, end_time):
+ """
+ Get records of the specified time range.
+ param:
+ start: the start time of the query.
+ end: the end time of the query.
+ return:
+ the list of file operations record.
+ """
+ data = get_org_file_ops_stats_by_day(org_id, start_time, end_time, get_time_offset())
+ ops_added_dict = get_init_data(start_time, end_time)
+ ops_visited_dict = get_init_data(start_time, end_time)
+ ops_deleted_dict = get_init_data(start_time, end_time)
+ ops_modified_dict = get_init_data(start_time, end_time)
+
+ # [{'number': 2,
+ # 'op_type': 'Added',
+ # 'timestamp': datetime.datetime(2022, 10, 27, 0, 0)}]
+ for item in data:
+ if item.get('op_type') == 'Added':
+ ops_added_dict[item.get('timestamp')] = item.get('number')
+ if item.get('op_type') == 'Visited':
+ ops_visited_dict[item.get('timestamp')] = item.get('number')
+ if item.get('op_type') == 'Deleted':
+ ops_deleted_dict[item.get('timestamp')] = item.get('number')
+ if item.get('op_type') == 'Modified':
+ ops_modified_dict[item.get('timestamp')] = item.get('number')
+
+ res_data = []
+ for k, v in list(ops_added_dict.items()):
+ res_data.append({'datetime': datetime_to_isoformat_timestr(k),
+ 'added': v,
+ 'visited': ops_visited_dict[k],
+ 'deleted': ops_deleted_dict[k],
+ 'modified': ops_modified_dict[k]})
+
+ return Response(sorted(res_data, key=lambda x: x['datetime']))
+
+
+class OrgTotalStorageView(APIView):
+
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ throttle_classes = (UserRateThrottle,)
+ permission_classes = (IsProVersion, IsOrgAdminUser)
+
+ @check_parameter
+ def get(self, request, org_id, start_time, end_time):
+
+ data = get_org_total_storage_stats_by_day(org_id, start_time, end_time, get_time_offset())
+
+ # [{'number': Decimal('2558796'),
+ # 'timestamp': datetime.datetime(2022, 11, 1, 0, 0)},
+ # {'number': Decimal('2558796'),
+ # 'timestamp': datetime.datetime(2022, 11, 2, 0, 0)}]
+ init_data = get_init_data(start_time, end_time)
+ for e in data:
+ init_data[e.get("timestamp")] = e.get("number")
+
+ res_data = []
+ for k, v in list(init_data.items()):
+ res_data.append({'datetime': datetime_to_isoformat_timestr(k), 'total_storage': v})
+
+ return Response(sorted(res_data, key=lambda x: x['datetime']))
+
+
+class OrgActiveUsersView(APIView):
+
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ throttle_classes = (UserRateThrottle,)
+ permission_classes = (IsProVersion, IsOrgAdminUser)
+
+ @check_parameter
+ def get(self, request, org_id, start_time, end_time):
+
+ data = get_org_user_activity_stats_by_day(org_id, start_time, end_time)
+
+ # [{'number': 1, 'timestamp': datetime.datetime(2022, 10, 27, 0, 0)},
+ # {'number': 2, 'timestamp': datetime.datetime(2022, 10, 31, 0, 0)},
+ # {'number': 2, 'timestamp': datetime.datetime(2022, 11, 1, 0, 0)},
+ # {'number': 1, 'timestamp': datetime.datetime(2022, 11, 2, 0, 0)}]
+ init_data = get_init_data(start_time, end_time)
+ for e in data:
+ init_data[e.get("timestamp")] = e.get("number")
+
+ res_data = []
+ for k, v in list(init_data.items()):
+ res_data.append({'datetime': datetime_to_isoformat_timestr(k), 'count': v})
+
+ return Response(sorted(res_data, key=lambda x: x['datetime']))
+
+
+class OrgSystemTrafficView(APIView):
+
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ throttle_classes = (UserRateThrottle,)
+ permission_classes = (IsProVersion, IsOrgAdminUser)
+
+ @check_parameter
+ def get(self, request, org_id, start_time, end_time):
+
+ op_type_list = ['web-file-upload', 'web-file-download',
+ 'sync-file-download', 'sync-file-upload',
+ 'link-file-upload', 'link-file-download']
+ init_count = [0] * 6
+ init_data = get_init_data(start_time, end_time,
+ dict(list(zip(op_type_list, init_count))))
+
+ data = get_org_traffic_by_day(org_id, start_time, end_time, get_time_offset())
+ # [(datetime.datetime(2022, 11, 1, 0, 0), 'web-file-upload', 2558798),
+ # (datetime.datetime(2022, 11, 2, 0, 0), 'web-file-upload', 48659279),
+ # (datetime.datetime(2022, 11, 3, 0, 0), 'link-file-download', 48658882),
+ # (datetime.datetime(2022, 11, 3, 0, 0), 'web-file-upload', 24329441)]
+ for e in data:
+ dt, op_type, count = e
+ init_data[dt].update({op_type: count})
+
+ res_data = []
+ for k, v in list(init_data.items()):
+ res = {'datetime': datetime_to_isoformat_timestr(k)}
+ res.update(v)
+ res_data.append(res)
+
+ return Response(sorted(res_data, key=lambda x: x['datetime']))
+
+
+class OrgUserTrafficView(APIView):
+
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ throttle_classes = (UserRateThrottle,)
+ permission_classes = (IsProVersion, IsOrgAdminUser)
+
+ def get(self, request, org_id):
+
+ month = request.GET.get("month", "")
+ if not month:
+ error_msg = "month invalid."
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ try:
+ month_obj = datetime.datetime.strptime(month, "%Y%m")
+ except Exception as e:
+ logger.error(e)
+ error_msg = "month %s invalid" % month
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ try:
+ page = int(request.GET.get('page', '1'))
+ per_page = int(request.GET.get('per_page', '25'))
+ except ValueError:
+ page = 1
+ per_page = 25
+ start = (page - 1) * per_page
+
+ order_by = request.GET.get('order_by', '')
+ filters = [
+ 'sync_file_upload', 'sync_file_download',
+ 'web_file_upload', 'web_file_download',
+ 'link_file_upload', 'link_file_download',
+ ]
+ if order_by not in filters and \
+ order_by not in map(lambda x: x + '_desc', filters):
+ order_by = 'link_file_download_desc'
+
+ # get one more item than per_page, to judge has_next_page
+ try:
+ traffics = seafevents_api.get_all_users_traffic_by_month(month_obj,
+ start,
+ start + per_page + 1,
+ order_by,
+ org_id)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ if len(traffics) == per_page + 1:
+ has_next_page = True
+ traffics = traffics[:per_page]
+ else:
+ has_next_page = False
+
+ user_monthly_traffic_list = []
+ for traffic in traffics:
+ info = {}
+ info['email'] = traffic['user']
+ info['name'] = email2nickname(traffic['user'])
+ info['sync_file_upload'] = traffic['sync_file_upload']
+ info['sync_file_download'] = traffic['sync_file_download']
+ info['web_file_upload'] = traffic['web_file_upload']
+ info['web_file_download'] = traffic['web_file_download']
+ info['link_file_upload'] = traffic['link_file_upload']
+ info['link_file_download'] = traffic['link_file_download']
+ user_monthly_traffic_list.append(info)
+
+ return Response({
+ 'user_monthly_traffic_list': user_monthly_traffic_list,
+ 'has_next_page': has_next_page
+ })
+
+
+class OrgUserTrafficExcelView(APIView):
+
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ throttle_classes = (UserRateThrottle,)
+ permission_classes = (IsProVersion, IsOrgAdminUser)
+
+ def get(self, request, org_id):
+
+ month = request.GET.get("month", "")
+ if not month:
+ error_msg = "month invalid."
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ try:
+ month_obj = datetime.datetime.strptime(month, "%Y%m")
+ except Exception:
+ error_msg = "Month %s invalid" % month
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ try:
+ res_data = seafevents_api.get_all_users_traffic_by_month(month_obj,
+ -1, -1,
+ org_id=org_id)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ data_list = []
+ head = [_("Time"), _("User"), _("Web Download") + ('(MB)'),
+ _("Sync Download") + ('(MB)'), _("Link Download") + ('(MB)'),
+ _("Web Upload") + ('(MB)'), _("Sync Upload") + ('(MB)'),
+ _("Link Upload") + ('(MB)')]
+
+ for data in res_data:
+ web_download = byte_to_mb(data['web_file_download'])
+ sync_download = byte_to_mb(data['sync_file_download'])
+ link_download = byte_to_mb(data['link_file_download'])
+ web_upload = byte_to_mb(data['web_file_upload'])
+ sync_upload = byte_to_mb(data['sync_file_upload'])
+ link_upload = byte_to_mb(data['link_file_upload'])
+
+ row = [month, data['user'], web_download, sync_download,
+ link_download, web_upload, sync_upload, link_upload]
+
+ data_list.append(row)
+
+ excel_name = "User Traffic %s" % month
+
+ try:
+ wb = write_xls(excel_name, head, data_list)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ response = HttpResponse(content_type='application/ms-excel')
+ response['Content-Disposition'] = 'attachment; filename="%s.xlsx"' % excel_name
+ wb.save(response)
+
+ return response
+
+
+class OrgUserStorageExcelView(APIView):
+
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ throttle_classes = (UserRateThrottle,)
+ permission_classes = (IsProVersion, IsOrgAdminUser)
+
+ def get(self, request, org_id):
+
+ org_id = int(org_id)
+ org = ccnet_api.get_org_by_id(org_id)
+ all_users = ccnet_api.get_org_users_by_url_prefix(org.url_prefix, -1, -1)
+
+ head = [_("Email"), _("Name"), _("Contact Email"),
+ _("Space Usage") + "(MB)", _("Space Quota") + "(MB)"]
+
+ data_list = []
+ for user in all_users:
+
+ user_email = user.email
+ user_name = email2nickname(user_email)
+ user_contact_email = email2contact_email(user_email)
+
+ _populate_user_quota_usage(user)
+ space_usage_MB = byte_to_mb(user.space_usage)
+ space_quota_MB = byte_to_mb(user.space_quota)
+
+ row = [user_email, user_name, user_contact_email,
+ space_usage_MB, space_quota_MB]
+
+ data_list.append(row)
+
+ excel_name = 'User Storage'
+ try:
+ wb = write_xls('users', head, data_list)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ response = HttpResponse(content_type='application/ms-excel')
+ response['Content-Disposition'] = 'attachment; filename="%s.xlsx"' % excel_name
+ wb.save(response)
+
+ return response
diff --git a/seahub/organizations/api/admin/users.py b/seahub/organizations/api/admin/users.py
index 9afaa660e3..5eb3649e55 100644
--- a/seahub/organizations/api/admin/users.py
+++ b/seahub/organizations/api/admin/users.py
@@ -1,5 +1,7 @@
# Copyright (c) 2012-2016 Seafile Ltd.
import logging
+from io import BytesIO
+from openpyxl import load_workbook
from rest_framework import status
from rest_framework.views import APIView
@@ -19,8 +21,10 @@ from seahub.base.models import UserLastLogin
from seahub.base.templatetags.seahub_tags import email2nickname, email2contact_email
from seahub.profile.models import Profile
from seahub.settings import SEND_EMAIL_ON_ADDING_SYSTEM_MEMBER
-from seahub.utils import is_valid_email, IS_EMAIL_CONFIGURED
+from seahub.utils import is_valid_email, IS_EMAIL_CONFIGURED, \
+ get_file_type_and_ext
from seahub.utils.file_size import get_file_size_unit
+from seahub.utils.error_msg import file_type_error_msg
from seahub.utils.timeutils import timestamp_to_isoformat_timestr, datetime_to_isoformat_timestr
from seahub.utils.licenseparse import user_number_over_limit
from seahub.views.sysadmin import send_user_add_mail
@@ -44,8 +48,8 @@ class OrgAdminUsers(APIView):
throttle_classes = (UserRateThrottle,)
permission_classes = (IsProVersion, IsOrgAdminUser)
- def get_info_of_users_order_by_quota_usage(self, org, all_users, direction,
- page, per_page):
+ def get_info_of_users_order_by_quota_usage(self, org, all_users,
+ direction, page, per_page):
# get user's quota usage info
user_usage_dict = {}
@@ -145,8 +149,11 @@ class OrgAdminUsers(APIView):
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
try:
- data = self.get_info_of_users_order_by_quota_usage(org, all_users, direction,
- current_page, per_page)
+ data = self.get_info_of_users_order_by_quota_usage(org,
+ all_users,
+ direction,
+ current_page,
+ per_page)
except Exception as e:
logger.error(e)
error_msg = 'Internal Server Error'
@@ -201,7 +208,6 @@ class OrgAdminUsers(APIView):
'page_next': page_next
})
-
def post(self, request, org_id):
"""Added an organization user, check member quota before adding.
"""
@@ -282,7 +288,7 @@ class OrgAdminUsers(APIView):
user_info['email'] = user.email
user_info['contact_email'] = email2contact_email(user.email)
user_info['last_login'] = None
- user_info['self_usage'] = 0 # get_org_user_self_usage(org.org_id, user.email)
+ user_info['self_usage'] = 0 # get_org_user_self_usage(org.org_id, user.email)
try:
user_info['quota'] = get_org_user_quota(org_id, user.email)
except SearpcError as e:
@@ -291,6 +297,7 @@ class OrgAdminUsers(APIView):
user_info['quota_usage'] = user_info['self_usage']
user_info['quota_total'] = user_info['quota']
+ user_info['create_time'] = user_info['ctime']
return Response(user_info)
@@ -589,3 +596,161 @@ class OrgAdminSearchUser(APIView):
user_list.append(user_info)
return Response({'user_list': user_list})
+
+
+class OrgAdminImportUsers(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ throttle_classes = (UserRateThrottle,)
+ permission_classes = (IsProVersion, IsOrgAdminUser)
+
+ def post(self, request, org_id):
+ """ Import users from xlsx file
+
+ Permission checking:
+ 1. admin user.
+ """
+
+ # resource check
+ org_id = int(org_id)
+ if not ccnet_api.get_org_by_id(org_id):
+ error_msg = 'Organization %s not found.' % org_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ # check plan
+ url_prefix = request.user.org.url_prefix
+ org_members = len(ccnet_api.get_org_users_by_url_prefix(url_prefix, -1, -1))
+
+ if ORG_MEMBER_QUOTA_ENABLED:
+ from seahub.organizations.models import OrgMemberQuota
+ org_members_quota = OrgMemberQuota.objects.get_quota(request.user.org.org_id)
+ if org_members_quota is not None and org_members >= org_members_quota:
+ err_msg = 'Failed. You can only invite %d members.' % org_members_quota
+ return api_error(status.HTTP_403_FORBIDDEN, err_msg)
+
+ xlsx_file = request.FILES.get('file', None)
+ if not xlsx_file:
+ error_msg = 'file can not be found.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ file_type, ext = get_file_type_and_ext(xlsx_file.name)
+ if ext != 'xlsx':
+ error_msg = file_type_error_msg(ext, 'xlsx')
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ content = xlsx_file.read()
+
+ try:
+ fs = BytesIO(content)
+ wb = load_workbook(filename=fs, read_only=True)
+ except Exception as e:
+ logger.error(e)
+
+ # example file is like:
+ # Email Password Name(Optional) Space Quota(MB, Optional)
+ # a@a.com a a 1024
+ # b@b.com b b 2048
+
+ rows = wb.worksheets[0].rows
+ records = []
+
+ # skip first row(head field).
+ next(rows)
+ for row in rows:
+ if not all(col.value is None for col in row):
+ records.append([col.value for col in row])
+
+ if user_number_over_limit(new_users=len(records)):
+ error_msg = 'The number of users exceeds the limit.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ result = {}
+ result['failed'] = []
+ result['success'] = []
+ for record in records:
+ if record[0]:
+ email = record[0].strip()
+ if not is_valid_email(email):
+ result['failed'].append({
+ 'email': email,
+ 'error_msg': 'email %s invalid.' % email
+ })
+ continue
+ else:
+ result['failed'].append({
+ 'email': '',
+ 'error_msg': 'email invalid.'
+ })
+ continue
+
+ if not record[1] or not record[1].strip():
+ result['failed'].append({
+ 'email': email,
+ 'error_msg': 'password invalid.'
+ })
+ continue
+ else:
+ password = record[1].strip()
+
+ try:
+ User.objects.get(email=email)
+ result['failed'].append({
+ 'email': email,
+ 'error_msg': 'user %s exists.' % email
+ })
+ continue
+ except User.DoesNotExist:
+ pass
+
+ User.objects.create_user(email, password, is_staff=False, is_active=True)
+ set_org_user(org_id, email)
+
+ if IS_EMAIL_CONFIGURED:
+ if SEND_EMAIL_ON_ADDING_SYSTEM_MEMBER:
+ try:
+ send_user_add_mail(request, email, password)
+ except Exception as e:
+ logger.error(str(e))
+
+ # update the user's optional info
+ # update nikename
+ if record[2]:
+ try:
+ nickname = record[2].strip()
+ if len(nickname) <= 64 and '/' not in nickname:
+ Profile.objects.add_or_update(email, nickname, '')
+ except Exception as e:
+ logger.error(e)
+
+ # update quota
+ if record[3]:
+ try:
+ space_quota_mb = int(record[3])
+ if space_quota_mb >= 0:
+ space_quota = int(space_quota_mb) * get_file_size_unit('MB')
+ seafile_api.set_org_user_quota(org_id, email, space_quota)
+ except Exception as e:
+ logger.error(e)
+
+ user = User.objects.get(email=email)
+
+ info = {}
+ info['email'] = email
+ info['name'] = email2nickname(email)
+ info['contact_email'] = email2contact_email(email)
+
+ info['is_staff'] = user.is_staff
+ info['is_active'] = user.is_active
+
+ info['quota_usage'] = 0
+ try:
+ info['quota_total'] = get_org_user_quota(org_id, email)
+ except SearpcError as e:
+ logger.error(e)
+ info['quota_total'] = -1
+
+ info['create_time'] = timestamp_to_isoformat_timestr(user.ctime)
+ info['last_login'] = None
+
+ result['success'].append(info)
+
+ return Response(result)
diff --git a/seahub/organizations/api_urls.py b/seahub/organizations/api_urls.py
index d22e169ed6..72036f9235 100644
--- a/seahub/organizations/api_urls.py
+++ b/seahub/organizations/api_urls.py
@@ -10,7 +10,8 @@ from .api.group_owned_libraries import (
AdminGroupOwnedLibraries, AdminGroupOwnedLibrary
)
from .api.group_members import AdminGroupMembers, AdminGroupMember
-from .api.admin.users import OrgAdminUser, OrgAdminUsers, OrgAdminSearchUser
+from .api.admin.users import OrgAdminUser, OrgAdminUsers, OrgAdminSearchUser, \
+ OrgAdminImportUsers
from .api.admin.user_set_password import OrgAdminUserSetPassword
from .api.admin.groups import OrgAdminGroups, OrgAdminGroup, OrgAdminSearchGroup
from .api.admin.repos import OrgAdminRepos, OrgAdminRepo
@@ -19,7 +20,38 @@ from .api.admin.links import OrgAdminLinks, OrgAdminLink
from .api.admin.logs import OrgAdminLogsFileAccess, OrgAdminLogsFileUpdate, OrgAdminLogsPermAudit
from .api.admin.user_repos import OrgAdminUserRepos, OrgAdminUserBesharedRepos
+from .api.admin.devices import OrgAdminDevices, OrgAdminDevicesErrors
+
+from .api.admin.statistics import OrgFileOperationsView, OrgTotalStorageView, \
+ OrgActiveUsersView, OrgSystemTrafficView, OrgUserTrafficView, \
+ OrgUserTrafficExcelView, OrgUserStorageExcelView
+
urlpatterns = [
+ url(r'^(?P\d+)/admin/statistics/file-operations/$',
+ OrgFileOperationsView.as_view(),
+ name='api-v2.1-org-admin-statistics-file-operations'),
+ url(r'^(?P\d+)/admin/statistics/total-storage/$',
+ OrgTotalStorageView.as_view(),
+ name='api-v2.1-org-admin-statistics-total-storage'),
+ url(r'^(?P\d+)/admin/statistics/active-users/$',
+ OrgActiveUsersView.as_view(),
+ name='api-v2.1-org-admin-statistics-active-users'),
+ url(r'^(?P\d+)/admin/statistics/system-traffic/$',
+ OrgSystemTrafficView.as_view(),
+ name='api-v2.1-org-admin-statistics-system-traffic'),
+ url(r'^(?P\d+)/admin/statistics/user-traffic/$',
+ OrgUserTrafficView.as_view(),
+ name='api-v2.1-org-admin-statistics-user-traffic'),
+ url(r'^(?P\d+)/admin/statistics/user-traffic/excel/$',
+ OrgUserTrafficExcelView.as_view(),
+ name='api-v2.1-org-admin-statistics-user-traffic-excel'),
+ url(r'^(?P\d+)/admin/statistics/user-storage/excel/$',
+ OrgUserStorageExcelView.as_view(),
+ name='api-v2.1-org-admin-statistics-user-storage-excel'),
+
+ url(r'^(?P\d+)/admin/devices/$', OrgAdminDevices.as_view(), name='api-v2.1-org-admin-devices'),
+ url(r'^(?P\d+)/admin/devices-errors/$', OrgAdminDevicesErrors.as_view(), name='api-v2.1-org-admin-devices-errors'),
+
url(r'^(?P\d+)/admin/address-book/groups/$', AdminAddressBookGroups.as_view(), name='api-admin-address-book-groups'),
url(r'^(?P\d+)/admin/address-book/groups/(?P\d+)/$', AdminAddressBookGroup.as_view(), name='api-admin-address-book-group'),
@@ -35,6 +67,7 @@ urlpatterns = [
url(r'^(?P\d+)/admin/groups/(?P\d+)/members/$', AdminGroupMembers.as_view(), name='api-admin-group-members'),
url(r'^(?P\d+)/admin/groups/(?P\d+)/members/(?P[^/]+)/$', AdminGroupMember.as_view(), name='api-admin-group-member'),
url(r'^(?P\d+)/admin/users/$', OrgAdminUsers.as_view(), name='api-v2.1-org-admin-users'),
+ url(r'^(?P\d+)/admin/import-users/$', OrgAdminImportUsers.as_view(), name='api-v2.1-org-admin-import-users'),
url(r'^(?P\d+)/admin/search-user/$', OrgAdminSearchUser.as_view(), name='api-v2.1-org-admin-search-user'),
url(r'^(?P\d+)/admin/users/(?P[^/]+)/$', OrgAdminUser.as_view(), name='api-v2.1-org-admin-user'),
url(r'^(?P\d+)/admin/users/(?P[^/]+)/set-password/', OrgAdminUserSetPassword.as_view(), name='api-v2.1-org-admin-user-reset-password'),
diff --git a/seahub/organizations/urls.py b/seahub/organizations/urls.py
index c9281475b4..2defabd852 100644
--- a/seahub/organizations/urls.py
+++ b/seahub/organizations/urls.py
@@ -7,6 +7,15 @@ urlpatterns = [
url(r'^add/$', org_add, name='org_add'),
url(r'^register/$', org_register, name='org_register'),
+ url(r'^statistics-admin/file/$', react_fake_view, name='org_statistics_admin_file'),
+ url(r'^statistics-admin/total-storage/$', react_fake_view, name='org_statistics_admin_total_storage'),
+ url(r'^statistics-admin/active-users/$', react_fake_view, name='org_statistics_admin_active_users'),
+ url(r'^statistics-admin/traffic/$', react_fake_view, name='org_statistics_admin_traffic'),
+
+ url(r'^deviceadmin/desktop-devices/$', react_fake_view, name='org_device_admin'),
+ url(r'^deviceadmin/mobile-devices/$', react_fake_view, name='org_device_admin_mobile_devices'),
+ url(r'^deviceadmin/devices-errors/$', react_fake_view, name='org_device_admin_devices_errors'),
+
url(r'^useradmin/$', react_fake_view, name='org_user_admin'),
url(r'^useradmin/search-users/$', react_fake_view, name='org_user_admin_search_users'),
url(r'^useradmin/admins/$', react_fake_view, name='org_useradmin_admins'),
@@ -25,7 +34,7 @@ urlpatterns = [
url(r'^logadmin/file-update/$', react_fake_view, name='org_log_file_update'),
url(r'^logadmin/perm-audit/$', react_fake_view, name='org_log_perm_audit'),
- url(r'^orgmanage/$', react_fake_view, name='org_manage'),
+ url(r'^info/$', react_fake_view, name='org_info'),
url(r'^departmentadmin/$', react_fake_view, name='org_department_admin'),
url(r'^departmentadmin/groups/(?P\d+)/', react_fake_view, name='org_department_admin'),
url(r'^associate/(?P.+)/$', org_associate, name='org_associate'),
diff --git a/seahub/utils/__init__.py b/seahub/utils/__init__.py
index 1fe301a9bd..6f967e9e9c 100644
--- a/seahub/utils/__init__.py
+++ b/seahub/utils/__init__.py
@@ -703,6 +703,12 @@ if EVENTS_CONFIG_FILE:
res = seafevents.get_user_activity_stats_by_day(session, start, end, offset)
return res
+ def get_org_user_activity_stats_by_day(org_id, start, end):
+ """
+ """
+ res = seafevents_api.get_org_user_activity_stats_by_day(org_id, start, end)
+ return res
+
def get_org_user_events(org_id, username, start, count):
return _get_events(username, start, count, org_id=org_id)
@@ -773,6 +779,12 @@ if EVENTS_CONFIG_FILE:
res = seafevents.get_file_ops_stats_by_day(session, start, end, offset)
return res
+ def get_org_file_ops_stats_by_day(org_id, start, end, offset):
+ """ return file audit record of sepcifiy time group by day.
+ """
+ res = seafevents_api.get_org_file_ops_stats_by_day(org_id, start, end, offset)
+ return res
+
def get_total_storage_stats_by_day(start, end, offset):
"""
"""
@@ -780,6 +792,12 @@ if EVENTS_CONFIG_FILE:
res = seafevents.get_total_storage_stats_by_day(session, start, end, offset)
return res
+ def get_org_total_storage_stats_by_day(org_id, start, end, offset):
+ """
+ """
+ res = seafevents_api.get_org_storage_stats_by_day(org_id, start, end, offset)
+ return res
+
def get_system_traffic_by_day(start, end, offset, op_type='all'):
with _get_seafevents_session() as session:
res = seafevents.get_system_traffic_by_day(session, start, end, offset, op_type)
@@ -848,6 +866,8 @@ else:
pass
def get_user_activity_stats_by_day():
pass
+ def get_org_user_activity_stats_by_day():
+ pass
def get_log_events_by_time():
pass
def get_org_user_events():
@@ -864,10 +884,16 @@ else:
pass
def get_file_ops_stats_by_day():
pass
+ def get_org_file_ops_stats_by_day():
+ pass
def get_total_storage_stats_by_day():
pass
+ def get_org_total_storage_stats_by_day():
+ pass
def get_system_traffic_by_day():
pass
+ def get_org_system_traffic_by_day():
+ pass
def get_org_traffic_by_day():
pass
def get_file_update_events():
diff --git a/seahub/views/sysadmin.py b/seahub/views/sysadmin.py
index b90759db45..834b569da9 100644
--- a/seahub/views/sysadmin.py
+++ b/seahub/views/sysadmin.py
@@ -50,7 +50,7 @@ from seahub.utils import IS_EMAIL_CONFIGURED, string2list, is_valid_username, \
is_pro_version, send_html_email, \
get_server_id, delete_virus_file, get_virus_file_by_vid, \
get_virus_files, FILE_AUDIT_ENABLED, get_max_upload_file_size, \
- get_site_name, seafevents_api
+ get_site_name, seafevents_api, is_org_context
from seahub.utils.ip import get_remote_ip
from seahub.utils.file_size import get_file_size_unit
from seahub.utils.ldap import get_ldap_info
@@ -779,7 +779,6 @@ def batch_user_make_admin(request):
return HttpResponse(json.dumps({'success': True,}), content_type=content_type)
@login_required
-@sys_staff_required
def batch_add_user_example(request):
""" get example file.
"""
@@ -787,20 +786,32 @@ def batch_add_user_example(request):
if not next_page:
next_page = SITE_ROOT
data_list = []
- head = [_('Email'),
- _('Password'),
- _('Name') + '(' + _('Optional') + ')',
- _('Role') + '(' + _('Optional') + ')',
- _('Space Quota') + '(MB, ' + _('Optional') + ')',
- 'Login ID']
- for i in range(5):
- username = "test" + str(i) + "@example.com"
- password = "123456"
- name = "test" + str(i)
- role = "default"
- quota = "1000"
- login_id = "login id " + str(i)
- data_list.append([username, password, name, role, quota, login_id])
+ if not is_org_context(request):
+ head = [_('Email'),
+ _('Password'),
+ _('Name') + '(' + _('Optional') + ')',
+ _('Role') + '(' + _('Optional') + ')',
+ _('Space Quota') + '(MB, ' + _('Optional') + ')',
+ 'Login ID']
+ for i in range(5):
+ username = "test" + str(i) + "@example.com"
+ password = "123456"
+ name = "test" + str(i)
+ role = "default"
+ quota = "1000"
+ login_id = "login id " + str(i)
+ data_list.append([username, password, name, role, quota, login_id])
+ else:
+ head = [_('Email'),
+ _('Password'),
+ _('Name') + '(' + _('Optional') + ')',
+ _('Space Quota') + '(MB, ' + _('Optional') + ')']
+ for i in range(5):
+ username = "test" + str(i) + "@example.com"
+ password = "123456"
+ name = "test" + str(i)
+ quota = "1000"
+ data_list.append([username, password, name, quota])
wb = write_xls('sample', head, data_list)
if not wb: