import NavBarTabs from 'sonar-ui-common/components/ui/NavBarTabs';
import { translate } from 'sonar-ui-common/helpers/l10n';
import { getBaseUrl } from 'sonar-ui-common/helpers/urls';
+import { AdminPageExtension } from '../../../../types/extension';
import { PendingPluginResult } from '../../../../types/plugins';
import { rawSizes } from '../../../theme';
import PendingPluginsActionNotif from './PendingPluginsActionNotif';
return this.isSomethingActive(urls);
}
+ isAudit() {
+ const urls = ['/admin/audit'];
+ return this.isSomethingActive(urls);
+ }
+
renderExtension = ({ key, name }: T.Extension) => {
return (
<li key={key}>
!this.isProjectsActive() &&
!this.isSystemActive() &&
!this.isSomethingActive(['/admin/extension/license/support']) &&
- !this.isMarketplace())
+ !this.isMarketplace() &&
+ !this.isAudit())
})}
href="#"
id="settings-navigation-configuration"
render() {
const { extensions, pendingPlugins } = this.props;
const hasSupportExtension = extensions.find(extension => extension.key === 'license/support');
+ const hasGovernanceExtension = extensions.find(
+ e => e.key === AdminPageExtension.GovernanceConsole
+ );
const totalPendingPlugins =
pendingPlugins.installing.length +
pendingPlugins.removing.length +
</IndexLink>
</li>
+ {hasGovernanceExtension && (
+ <li>
+ <IndexLink activeClassName="active" to="/admin/audit">
+ {translate('audit_logs.page')}
+ </IndexLink>
+ </li>
+ )}
+
{hasSupportExtension && (
<li>
<IndexLink activeClassName="active" to="/admin/extension/license/support">
*/
import { shallow } from 'enzyme';
import * as React from 'react';
+import { AdminPageExtension } from '../../../../../types/extension';
import SettingsNav from '../SettingsNav';
it('should work with extensions', () => {
expect(wrapper.find('ContextNavBar').prop('notif')).toMatchSnapshot();
});
+it('should render correctly when governance is active', () => {
+ expect(
+ shallowRender({
+ extensions: [{ key: AdminPageExtension.GovernanceConsole, name: 'governance' }]
+ })
+ ).toMatchSnapshot();
+});
+
function shallowRender(props: Partial<SettingsNav['props']> = {}) {
return shallow(
<SettingsNav
exports[`should display restart notif 1`] = `<SystemRestartNotif />`;
+exports[`should render correctly when governance is active 1`] = `
+<ContextNavBar
+ height={72}
+ id="context-navigation"
+>
+ <header
+ className="navbar-context-header"
+ >
+ <h1>
+ layout.settings
+ </h1>
+ </header>
+ <NavBarTabs>
+ <Dropdown
+ overlay={
+ <ul
+ className="menu"
+ >
+ <li>
+ <IndexLink
+ activeClassName="active"
+ to="/admin/settings"
+ >
+ settings.page
+ </IndexLink>
+ </li>
+ <li>
+ <IndexLink
+ activeClassName="active"
+ to="/admin/settings/encryption"
+ >
+ property.category.security.encryption
+ </IndexLink>
+ </li>
+ <li>
+ <IndexLink
+ activeClassName="active"
+ to="/admin/webhooks"
+ >
+ webhooks.page
+ </IndexLink>
+ </li>
+ <li>
+ <Link
+ activeClassName="active"
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to="/admin/extension/governance/views_console"
+ >
+ governance
+ </Link>
+ </li>
+ </ul>
+ }
+ tagName="li"
+ >
+ <Component />
+ </Dropdown>
+ <Dropdown
+ overlay={
+ <ul
+ className="menu"
+ >
+ <li>
+ <IndexLink
+ activeClassName="active"
+ to="/admin/users"
+ >
+ users.page
+ </IndexLink>
+ </li>
+ <li>
+ <IndexLink
+ activeClassName="active"
+ to="/admin/groups"
+ >
+ user_groups.page
+ </IndexLink>
+ </li>
+ <li>
+ <IndexLink
+ activeClassName="active"
+ to="/admin/permissions"
+ >
+ global_permissions.page
+ </IndexLink>
+ </li>
+ <li>
+ <IndexLink
+ activeClassName="active"
+ to="/admin/permission_templates"
+ >
+ permission_templates
+ </IndexLink>
+ </li>
+ </ul>
+ }
+ tagName="li"
+ >
+ <Component />
+ </Dropdown>
+ <Dropdown
+ overlay={
+ <ul
+ className="menu"
+ >
+ <li>
+ <IndexLink
+ activeClassName="active"
+ to="/admin/projects_management"
+ >
+ management
+ </IndexLink>
+ </li>
+ <li>
+ <IndexLink
+ activeClassName="active"
+ to="/admin/background_tasks"
+ >
+ background_tasks.page
+ </IndexLink>
+ </li>
+ </ul>
+ }
+ tagName="li"
+ >
+ <Component />
+ </Dropdown>
+ <li>
+ <IndexLink
+ activeClassName="active"
+ to="/admin/system"
+ >
+ sidebar.system
+ </IndexLink>
+ </li>
+ <li>
+ <IndexLink
+ activeClassName="active"
+ to="/admin/marketplace"
+ >
+ marketplace.page
+ </IndexLink>
+ </li>
+ <li>
+ <IndexLink
+ activeClassName="active"
+ to="/admin/audit"
+ >
+ audit_logs.page
+ </IndexLink>
+ </li>
+ </NavBarTabs>
+</ContextNavBar>
+`;
+
exports[`should work with extensions 1`] = `
<ContextNavBar
height={72}
import aboutRoutes from '../../apps/about/routes';
import accountRoutes from '../../apps/account/routes';
import applicationConsoleRoutes from '../../apps/application-console/routes';
+import auditLogsRoutes from '../../apps/audit-logs/routes';
import backgroundTasksRoutes from '../../apps/background-tasks/routes';
import codeRoutes from '../../apps/code/routes';
import codingRulesRoutes from '../../apps/coding-rules/routes';
import('../components/extensions/GlobalAdminPageExtension')
)}
/>
+ <RouteWithChildRoutes path="audit" childRoutes={auditLogsRoutes} />
<RouteWithChildRoutes path="background_tasks" childRoutes={backgroundTasksRoutes} />
<RouteWithChildRoutes path="groups" childRoutes={groupsRoutes} />
<RouteWithChildRoutes path="permission_templates" childRoutes={permissionTemplatesRoutes} />
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import * as React from 'react';
+import { connect } from 'react-redux';
+import { getAppState, getGlobalSettingValue, Store } from '../../../store/rootReducer';
+import { AdminPageExtension } from '../../../types/extension';
+import { fetchValues } from '../../settings/store/actions';
+import '../style.css';
+import { HousekeepingPolicy, RangeOption } from '../utils';
+import AuditAppRenderer from './AuditAppRenderer';
+
+interface Props {
+ auditHousekeepingPolicy: HousekeepingPolicy;
+ fetchValues: typeof fetchValues;
+ hasGovernanceExtension?: boolean;
+}
+
+interface State {
+ dateRange?: { from?: Date; to?: Date };
+ downloadStarted: boolean;
+ selection: RangeOption;
+}
+
+export class AuditApp extends React.PureComponent<Props, State> {
+ state: State = {
+ downloadStarted: false,
+ selection: RangeOption.Today
+ };
+
+ componentDidMount() {
+ const { hasGovernanceExtension } = this.props;
+ if (hasGovernanceExtension) {
+ this.props.fetchValues('sonar.dbcleaner.auditHousekeeping');
+ }
+ }
+
+ handleDateSelection = (dateRange: { from?: Date; to?: Date }) =>
+ this.setState({ dateRange, downloadStarted: false, selection: RangeOption.Custom });
+
+ handleOptionSelection = (selection: RangeOption) =>
+ this.setState({ dateRange: undefined, downloadStarted: false, selection });
+
+ handleStartDownload = () => {
+ setTimeout(() => {
+ this.setState({ downloadStarted: true });
+ }, 0);
+ };
+
+ render() {
+ const { hasGovernanceExtension, auditHousekeepingPolicy } = this.props;
+
+ return hasGovernanceExtension ? (
+ <AuditAppRenderer
+ handleDateSelection={this.handleDateSelection}
+ handleOptionSelection={this.handleOptionSelection}
+ handleStartDownload={this.handleStartDownload}
+ housekeepingPolicy={auditHousekeepingPolicy || HousekeepingPolicy.Monthly}
+ {...this.state}
+ />
+ ) : null;
+ }
+}
+
+const mapDispatchToProps = { fetchValues };
+
+const mapStateToProps = (state: Store) => {
+ const settingValue = getGlobalSettingValue(state, 'sonar.dbcleaner.auditHousekeeping');
+ const { adminPages } = getAppState(state);
+ const hasGovernanceExtension = Boolean(
+ adminPages?.find(e => e.key === AdminPageExtension.GovernanceConsole)
+ );
+ return {
+ auditHousekeepingPolicy: settingValue?.value as HousekeepingPolicy,
+ hasGovernanceExtension
+ };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(AuditApp);
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { subDays } from 'date-fns';
+import * as React from 'react';
+import { Helmet } from 'react-helmet-async';
+import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router';
+import Radio from 'sonar-ui-common/components/controls/Radio';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import Suggestions from '../../../app/components/embed-docs-modal/Suggestions';
+import DateRangeInput from '../../../components/controls/DateRangeInput';
+import '../style.css';
+import { HousekeepingPolicy, now, RangeOption } from '../utils';
+import DownloadButton from './DownloadButton';
+
+export interface AuditAppRendererProps {
+ dateRange?: { from?: Date; to?: Date };
+ downloadStarted: boolean;
+ handleOptionSelection: (option: RangeOption) => void;
+ handleDateSelection: (dateRange: { from?: Date; to?: Date }) => void;
+ handleStartDownload: () => void;
+ housekeepingPolicy: HousekeepingPolicy;
+ selection: RangeOption;
+}
+
+const HOUSEKEEPING_MONTH_THRESHOLD = 30;
+const HOUSEKEEPING_TRIMESTER_THRESHOLD = 90;
+
+const HOUSEKEEPING_POLICY_VALUES = {
+ [HousekeepingPolicy.Weekly]: 7,
+ [HousekeepingPolicy.Monthly]: 30,
+ [HousekeepingPolicy.Trimestrial]: 90,
+ [HousekeepingPolicy.Yearly]: 365
+};
+
+const getRangeOptions = (housekeepingPolicy: HousekeepingPolicy) => {
+ const rangeOptions = [RangeOption.Today, RangeOption.Week];
+
+ if (HOUSEKEEPING_POLICY_VALUES[housekeepingPolicy] >= HOUSEKEEPING_MONTH_THRESHOLD) {
+ rangeOptions.push(RangeOption.Month);
+ }
+
+ if (HOUSEKEEPING_POLICY_VALUES[housekeepingPolicy] >= HOUSEKEEPING_TRIMESTER_THRESHOLD) {
+ rangeOptions.push(RangeOption.Trimester);
+ }
+
+ rangeOptions.push(RangeOption.Custom);
+
+ return rangeOptions;
+};
+
+export default function AuditAppRenderer(props: AuditAppRendererProps) {
+ const { dateRange, downloadStarted, housekeepingPolicy, selection } = props;
+
+ return (
+ <div className="page page-limited" id="marketplace-page">
+ <Suggestions suggestions="audit-logs" />
+ <Helmet title={translate('audit_logs.page')} />
+
+ <h1 className="spacer-bottom">{translate('audit_logs.page')}</h1>
+ <p className="big-spacer-bottom">
+ {translate('audit_logs.page.description.1')}
+ <br />
+ <FormattedMessage
+ id="audit_logs.page.description.2"
+ defaultMessage={translate('audit_logs.page.description.2')}
+ values={{
+ housekeeping: translate('audit_logs.houskeeping_policy', housekeepingPolicy),
+ link: (
+ <Link to={{ pathname: '/admin/settings', query: { category: 'housekeeping' } }}>
+ {translate('audit_logs.page.description.link')}
+ </Link>
+ )
+ }}
+ />
+ </p>
+
+ <div className="huge-spacer-bottom">
+ <h2 className="big-spacer-bottom">{translate('audit_logs.download')}</h2>
+
+ <ul>
+ {getRangeOptions(housekeepingPolicy).map(option => (
+ <li key={option} className="spacer-bottom">
+ <Radio
+ checked={selection === option}
+ onCheck={props.handleOptionSelection}
+ value={option}>
+ {translate('audit_logs.range_option', option)}
+ </Radio>
+ </li>
+ ))}
+ </ul>
+
+ <DateRangeInput
+ className="big-spacer-left"
+ onChange={props.handleDateSelection}
+ minDate={subDays(now(), HOUSEKEEPING_POLICY_VALUES[housekeepingPolicy])}
+ maxDate={now()}
+ value={dateRange}
+ />
+ </div>
+
+ <DownloadButton
+ dateRange={dateRange}
+ downloadStarted={downloadStarted}
+ onStartDownload={props.handleStartDownload}
+ selection={selection}
+ />
+ </div>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import * as classNames from 'classnames';
+import { endOfDay, startOfDay, subDays } from 'date-fns';
+import * as React from 'react';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import { getBaseUrl } from '../../../helpers/system';
+import '../style.css';
+import { now, RangeOption } from '../utils';
+
+export interface DownloadButtonProps {
+ dateRange?: { from?: Date; to?: Date };
+ downloadStarted: boolean;
+ onStartDownload: () => void;
+ selection: RangeOption;
+}
+
+const RANGE_OPTION_START = {
+ [RangeOption.Today]: () => now(),
+ [RangeOption.Week]: () => subDays(now(), 7),
+ [RangeOption.Month]: () => subDays(now(), 30),
+ [RangeOption.Trimester]: () => subDays(now(), 90)
+};
+
+const toISODateString = (date: Date) => date.toISOString();
+
+function getRangeParams(selection: RangeOption, dateRange?: { from?: Date; to?: Date }) {
+ if (selection === RangeOption.Custom) {
+ // dateRange should be complete if 'custom' is selected
+ if (!(dateRange?.to && dateRange?.from)) {
+ return '';
+ }
+
+ return new URLSearchParams({
+ from: toISODateString(startOfDay(dateRange.from)),
+ to: toISODateString(endOfDay(dateRange.to))
+ }).toString();
+ }
+
+ return new URLSearchParams({
+ from: toISODateString(startOfDay(RANGE_OPTION_START[selection]())),
+ to: toISODateString(now())
+ }).toString();
+}
+
+export default function DownloadButton(props: DownloadButtonProps) {
+ const { dateRange, downloadStarted, selection } = props;
+
+ const downloadDisabled =
+ downloadStarted ||
+ (selection === RangeOption.Custom &&
+ (dateRange?.from === undefined || dateRange?.to === undefined));
+
+ const downloadUrl = downloadDisabled
+ ? '#'
+ : `${getBaseUrl()}/api/audit_logs/download?${getRangeParams(selection, dateRange)}`;
+
+ return (
+ <>
+ <a
+ className={classNames('button button-primary', { disabled: downloadDisabled })}
+ download="audit-logs.json"
+ onClick={downloadDisabled ? undefined : props.onStartDownload}
+ href={downloadUrl}
+ rel="noopener noreferrer"
+ target="_blank">
+ {translate('download_verb')}
+ </a>
+
+ {downloadStarted && (
+ <div className="spacer-top">
+ <p>{translate('audit_logs.download_start.sentence.1')}</p>
+ <p>{translate('audit_logs.download_start.sentence.2')}</p>
+ <br />
+ <p>{translate('audit_logs.download_start.sentence.3')}</p>
+ </div>
+ )}
+ </>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { subDays } from 'date-fns';
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import { HousekeepingPolicy, RangeOption } from '../../utils';
+import { AuditApp } from '../AuditApp';
+import AuditAppRenderer from '../AuditAppRenderer';
+
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should do nothing if governance is not available', async () => {
+ const fetchValues = jest.fn();
+ const wrapper = shallowRender({ fetchValues, hasGovernanceExtension: false });
+ await waitAndUpdate(wrapper);
+
+ expect(wrapper.type()).toBeNull();
+ expect(fetchValues).not.toBeCalled();
+});
+
+it('should fetch houskeeping policy on mount', async () => {
+ const fetchValues = jest.fn();
+ const wrapper = shallowRender({ fetchValues });
+ await waitAndUpdate(wrapper);
+ expect(fetchValues).toBeCalled();
+});
+
+it('should handle date selection', () => {
+ const wrapper = shallowRender();
+ const range = { from: subDays(new Date(), 2), to: new Date() };
+
+ expect(wrapper.state().selection).toBe(RangeOption.Today);
+
+ wrapper
+ .find(AuditAppRenderer)
+ .props()
+ .handleDateSelection(range);
+
+ expect(wrapper.state().selection).toBe(RangeOption.Custom);
+ expect(wrapper.state().dateRange).toBe(range);
+});
+
+it('should handle predefined selection', () => {
+ const wrapper = shallowRender();
+ const dateRange = { from: subDays(new Date(), 2), to: new Date() };
+
+ wrapper.setState({ dateRange, selection: RangeOption.Custom });
+
+ wrapper
+ .find(AuditAppRenderer)
+ .props()
+ .handleOptionSelection(RangeOption.Week);
+
+ expect(wrapper.state().selection).toBe(RangeOption.Week);
+ expect(wrapper.state().dateRange).toBeUndefined();
+});
+
+function shallowRender(props: Partial<AuditApp['props']> = {}) {
+ return shallow<AuditApp>(
+ <AuditApp
+ auditHousekeepingPolicy={HousekeepingPolicy.Monthly}
+ fetchValues={jest.fn()}
+ hasGovernanceExtension={true}
+ {...props}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { HousekeepingPolicy, RangeOption } from '../../utils';
+import AuditAppRenderer, { AuditAppRendererProps } from '../AuditAppRenderer';
+
+jest.mock('../../utils', () => {
+ const { HousekeepingPolicy, RangeOption } = jest.requireActual('../../utils');
+ const now = new Date('2020-07-21T12:00:00Z');
+
+ return {
+ HousekeepingPolicy,
+ now: jest.fn().mockReturnValue(now),
+ RangeOption
+ };
+});
+
+it.each([
+ [HousekeepingPolicy.Weekly],
+ [HousekeepingPolicy.Monthly],
+ [HousekeepingPolicy.Trimestrial],
+ [HousekeepingPolicy.Yearly]
+])('should render correctly for %s housekeeping policy', housekeepingPolicy => {
+ expect(shallowRender({ housekeepingPolicy })).toMatchSnapshot();
+});
+
+function shallowRender(props: Partial<AuditAppRendererProps> = {}) {
+ return shallow(
+ <AuditAppRenderer
+ downloadStarted={false}
+ handleDateSelection={jest.fn()}
+ handleOptionSelection={jest.fn()}
+ handleStartDownload={jest.fn()}
+ housekeepingPolicy={HousekeepingPolicy.Yearly}
+ selection={RangeOption.Today}
+ {...props}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { subDays } from 'date-fns';
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { RangeOption } from '../../utils';
+import DownloadButton, { DownloadButtonProps } from '../DownloadButton';
+
+jest.mock('date-fns', () => {
+ const { subDays } = jest.requireActual('date-fns');
+ return {
+ endOfDay: jest.fn().mockImplementation(d => d),
+ startOfDay: jest.fn().mockImplementation(d => d),
+ subDays
+ };
+});
+
+jest.mock('../../utils', () => {
+ const { HousekeepingPolicy, RangeOption } = jest.requireActual('../../utils');
+ const now = new Date('2020-07-21T12:00:00Z');
+
+ return {
+ HousekeepingPolicy,
+ now: jest.fn().mockReturnValue(now),
+ RangeOption
+ };
+});
+
+it.each([[RangeOption.Today], [RangeOption.Week], [RangeOption.Month], [RangeOption.Trimester]])(
+ 'should render correctly for %s',
+ selection => {
+ expect(shallowRender({ selection })).toMatchSnapshot('default');
+ }
+);
+
+it('should render correctly for custom range', () => {
+ const baseDate = new Date('2020-07-21T12:00:00Z');
+
+ expect(shallowRender({ selection: RangeOption.Custom })).toMatchSnapshot('no dates');
+ expect(
+ shallowRender({
+ dateRange: { from: subDays(baseDate, 2), to: baseDate },
+ selection: RangeOption.Custom
+ })
+ ).toMatchSnapshot('with dates');
+});
+
+it('should handle download', () => {
+ const onStartDownload = jest.fn();
+ const wrapper = shallowRender({ onStartDownload });
+
+ wrapper.find('a').simulate('click');
+ wrapper.setProps({ downloadStarted: true });
+ wrapper.find('a').simulate('click');
+
+ expect(onStartDownload).toBeCalledTimes(1);
+});
+
+function shallowRender(props: Partial<DownloadButtonProps> = {}) {
+ return shallow<DownloadButtonProps>(
+ <DownloadButton
+ downloadStarted={false}
+ selection={RangeOption.Today}
+ onStartDownload={jest.fn()}
+ {...props}
+ />
+ );
+}
--- /dev/null
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<AuditAppRenderer
+ downloadStarted={false}
+ handleDateSelection={[Function]}
+ handleOptionSelection={[Function]}
+ handleStartDownload={[Function]}
+ housekeepingPolicy="monthly"
+ selection="today"
+/>
+`;
--- /dev/null
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly for monthly housekeeping policy 1`] = `
+<div
+ className="page page-limited"
+ id="marketplace-page"
+>
+ <Suggestions
+ suggestions="audit-logs"
+ />
+ <Helmet
+ defer={true}
+ encodeSpecialCharacters={true}
+ title="audit_logs.page"
+ />
+ <h1
+ className="spacer-bottom"
+ >
+ audit_logs.page
+ </h1>
+ <p
+ className="big-spacer-bottom"
+ >
+ audit_logs.page.description.1
+ <br />
+ <FormattedMessage
+ defaultMessage="audit_logs.page.description.2"
+ id="audit_logs.page.description.2"
+ values={
+ Object {
+ "housekeeping": "audit_logs.houskeeping_policy.monthly",
+ "link": <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/admin/settings",
+ "query": Object {
+ "category": "housekeeping",
+ },
+ }
+ }
+ >
+ audit_logs.page.description.link
+ </Link>,
+ }
+ }
+ />
+ </p>
+ <div
+ className="huge-spacer-bottom"
+ >
+ <h2
+ className="big-spacer-bottom"
+ >
+ audit_logs.download
+ </h2>
+ <ul>
+ <li
+ className="spacer-bottom"
+ key="today"
+ >
+ <Radio
+ checked={true}
+ onCheck={[MockFunction]}
+ value="today"
+ >
+ audit_logs.range_option.today
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="7days"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="7days"
+ >
+ audit_logs.range_option.7days
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="30days"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="30days"
+ >
+ audit_logs.range_option.30days
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="custom"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="custom"
+ >
+ audit_logs.range_option.custom
+ </Radio>
+ </li>
+ </ul>
+ <DateRangeInput
+ className="big-spacer-left"
+ maxDate={2020-07-21T12:00:00.000Z}
+ minDate={2020-06-21T12:00:00.000Z}
+ onChange={[MockFunction]}
+ />
+ </div>
+ <DownloadButton
+ downloadStarted={false}
+ onStartDownload={[MockFunction]}
+ selection="today"
+ />
+</div>
+`;
+
+exports[`should render correctly for trimestrial housekeeping policy 1`] = `
+<div
+ className="page page-limited"
+ id="marketplace-page"
+>
+ <Suggestions
+ suggestions="audit-logs"
+ />
+ <Helmet
+ defer={true}
+ encodeSpecialCharacters={true}
+ title="audit_logs.page"
+ />
+ <h1
+ className="spacer-bottom"
+ >
+ audit_logs.page
+ </h1>
+ <p
+ className="big-spacer-bottom"
+ >
+ audit_logs.page.description.1
+ <br />
+ <FormattedMessage
+ defaultMessage="audit_logs.page.description.2"
+ id="audit_logs.page.description.2"
+ values={
+ Object {
+ "housekeeping": "audit_logs.houskeeping_policy.trimestrial",
+ "link": <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/admin/settings",
+ "query": Object {
+ "category": "housekeeping",
+ },
+ }
+ }
+ >
+ audit_logs.page.description.link
+ </Link>,
+ }
+ }
+ />
+ </p>
+ <div
+ className="huge-spacer-bottom"
+ >
+ <h2
+ className="big-spacer-bottom"
+ >
+ audit_logs.download
+ </h2>
+ <ul>
+ <li
+ className="spacer-bottom"
+ key="today"
+ >
+ <Radio
+ checked={true}
+ onCheck={[MockFunction]}
+ value="today"
+ >
+ audit_logs.range_option.today
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="7days"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="7days"
+ >
+ audit_logs.range_option.7days
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="30days"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="30days"
+ >
+ audit_logs.range_option.30days
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="90days"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="90days"
+ >
+ audit_logs.range_option.90days
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="custom"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="custom"
+ >
+ audit_logs.range_option.custom
+ </Radio>
+ </li>
+ </ul>
+ <DateRangeInput
+ className="big-spacer-left"
+ maxDate={2020-07-21T12:00:00.000Z}
+ minDate={2020-04-22T12:00:00.000Z}
+ onChange={[MockFunction]}
+ />
+ </div>
+ <DownloadButton
+ downloadStarted={false}
+ onStartDownload={[MockFunction]}
+ selection="today"
+ />
+</div>
+`;
+
+exports[`should render correctly for weekly housekeeping policy 1`] = `
+<div
+ className="page page-limited"
+ id="marketplace-page"
+>
+ <Suggestions
+ suggestions="audit-logs"
+ />
+ <Helmet
+ defer={true}
+ encodeSpecialCharacters={true}
+ title="audit_logs.page"
+ />
+ <h1
+ className="spacer-bottom"
+ >
+ audit_logs.page
+ </h1>
+ <p
+ className="big-spacer-bottom"
+ >
+ audit_logs.page.description.1
+ <br />
+ <FormattedMessage
+ defaultMessage="audit_logs.page.description.2"
+ id="audit_logs.page.description.2"
+ values={
+ Object {
+ "housekeeping": "audit_logs.houskeeping_policy.weekly",
+ "link": <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/admin/settings",
+ "query": Object {
+ "category": "housekeeping",
+ },
+ }
+ }
+ >
+ audit_logs.page.description.link
+ </Link>,
+ }
+ }
+ />
+ </p>
+ <div
+ className="huge-spacer-bottom"
+ >
+ <h2
+ className="big-spacer-bottom"
+ >
+ audit_logs.download
+ </h2>
+ <ul>
+ <li
+ className="spacer-bottom"
+ key="today"
+ >
+ <Radio
+ checked={true}
+ onCheck={[MockFunction]}
+ value="today"
+ >
+ audit_logs.range_option.today
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="7days"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="7days"
+ >
+ audit_logs.range_option.7days
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="custom"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="custom"
+ >
+ audit_logs.range_option.custom
+ </Radio>
+ </li>
+ </ul>
+ <DateRangeInput
+ className="big-spacer-left"
+ maxDate={2020-07-21T12:00:00.000Z}
+ minDate={2020-07-14T12:00:00.000Z}
+ onChange={[MockFunction]}
+ />
+ </div>
+ <DownloadButton
+ downloadStarted={false}
+ onStartDownload={[MockFunction]}
+ selection="today"
+ />
+</div>
+`;
+
+exports[`should render correctly for yearly housekeeping policy 1`] = `
+<div
+ className="page page-limited"
+ id="marketplace-page"
+>
+ <Suggestions
+ suggestions="audit-logs"
+ />
+ <Helmet
+ defer={true}
+ encodeSpecialCharacters={true}
+ title="audit_logs.page"
+ />
+ <h1
+ className="spacer-bottom"
+ >
+ audit_logs.page
+ </h1>
+ <p
+ className="big-spacer-bottom"
+ >
+ audit_logs.page.description.1
+ <br />
+ <FormattedMessage
+ defaultMessage="audit_logs.page.description.2"
+ id="audit_logs.page.description.2"
+ values={
+ Object {
+ "housekeeping": "audit_logs.houskeeping_policy.yearly",
+ "link": <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/admin/settings",
+ "query": Object {
+ "category": "housekeeping",
+ },
+ }
+ }
+ >
+ audit_logs.page.description.link
+ </Link>,
+ }
+ }
+ />
+ </p>
+ <div
+ className="huge-spacer-bottom"
+ >
+ <h2
+ className="big-spacer-bottom"
+ >
+ audit_logs.download
+ </h2>
+ <ul>
+ <li
+ className="spacer-bottom"
+ key="today"
+ >
+ <Radio
+ checked={true}
+ onCheck={[MockFunction]}
+ value="today"
+ >
+ audit_logs.range_option.today
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="7days"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="7days"
+ >
+ audit_logs.range_option.7days
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="30days"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="30days"
+ >
+ audit_logs.range_option.30days
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="90days"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="90days"
+ >
+ audit_logs.range_option.90days
+ </Radio>
+ </li>
+ <li
+ className="spacer-bottom"
+ key="custom"
+ >
+ <Radio
+ checked={false}
+ onCheck={[MockFunction]}
+ value="custom"
+ >
+ audit_logs.range_option.custom
+ </Radio>
+ </li>
+ </ul>
+ <DateRangeInput
+ className="big-spacer-left"
+ maxDate={2020-07-21T12:00:00.000Z}
+ minDate={2019-07-22T12:00:00.000Z}
+ onChange={[MockFunction]}
+ />
+ </div>
+ <DownloadButton
+ downloadStarted={false}
+ onStartDownload={[MockFunction]}
+ selection="today"
+ />
+</div>
+`;
--- /dev/null
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly for 7days: default 1`] = `
+<Fragment>
+ <a
+ className="button button-primary"
+ download="audit_logs.json"
+ href="/api/audit_logs/download?from=2020-07-14T12%3A00%3A00.000Z&to=2020-07-21T12%3A00%3A00.000Z"
+ onClick={[MockFunction]}
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ download_verb
+ </a>
+</Fragment>
+`;
+
+exports[`should render correctly for 30days: default 1`] = `
+<Fragment>
+ <a
+ className="button button-primary"
+ download="audit_logs.json"
+ href="/api/audit_logs/download?from=2020-06-21T12%3A00%3A00.000Z&to=2020-07-21T12%3A00%3A00.000Z"
+ onClick={[MockFunction]}
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ download_verb
+ </a>
+</Fragment>
+`;
+
+exports[`should render correctly for 90days: default 1`] = `
+<Fragment>
+ <a
+ className="button button-primary"
+ download="audit_logs.json"
+ href="/api/audit_logs/download?from=2020-04-22T12%3A00%3A00.000Z&to=2020-07-21T12%3A00%3A00.000Z"
+ onClick={[MockFunction]}
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ download_verb
+ </a>
+</Fragment>
+`;
+
+exports[`should render correctly for custom range: no dates 1`] = `
+<Fragment>
+ <a
+ className="button button-primary disabled"
+ download="audit_logs.json"
+ href="#"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ download_verb
+ </a>
+</Fragment>
+`;
+
+exports[`should render correctly for custom range: with dates 1`] = `
+<Fragment>
+ <a
+ className="button button-primary"
+ download="audit_logs.json"
+ href="/api/audit_logs/download?from=2020-07-19T12%3A00%3A00.000Z&to=2020-07-21T12%3A00%3A00.000Z"
+ onClick={[MockFunction]}
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ download_verb
+ </a>
+</Fragment>
+`;
+
+exports[`should render correctly for today: default 1`] = `
+<Fragment>
+ <a
+ className="button button-primary"
+ download="audit_logs.json"
+ href="/api/audit_logs/download?from=2020-07-21T12%3A00%3A00.000Z&to=2020-07-21T12%3A00%3A00.000Z"
+ onClick={[MockFunction]}
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ download_verb
+ </a>
+</Fragment>
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { lazyLoadComponent } from 'sonar-ui-common/components/lazyLoadComponent';
+
+const routes = [
+ {
+ indexRoute: { component: lazyLoadComponent(() => import('./components/AuditApp')) }
+ }
+];
+
+export default routes;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+export enum HousekeepingPolicy {
+ Weekly = 'weekly',
+ Monthly = 'monthly',
+ Trimestrial = 'trimestrial',
+ Yearly = 'yearly'
+}
+
+export enum RangeOption {
+ Today = 'today',
+ Week = '7days',
+ Month = '30days',
+ Trimester = '90days',
+ Custom = 'custom'
+}
+
+export function now() {
+ return new Date();
+}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as classNames from 'classnames';
+import { max, min } from 'date-fns';
import * as React from 'react';
import { translate } from 'sonar-ui-common/helpers/l10n';
import DateInput from './DateInput';
};
render() {
+ const { minDate, maxDate } = this.props;
+
return (
<div className={classNames('display-inline-flex-center', this.props.className)}>
<DateInput
currentMonth={this.to}
data-test="from"
highlightTo={this.to}
- maxDate={this.to}
+ minDate={minDate}
+ maxDate={maxDate && this.to ? min(maxDate, this.to) : maxDate || this.to}
onChange={this.handleFromChange}
placeholder={translate('start_date')}
value={this.from}
currentMonth={this.from}
data-test="to"
highlightFrom={this.from}
- minDate={this.from}
+ minDate={minDate && this.from ? max(minDate, this.from) : minDate || this.from}
+ maxDate={maxDate}
onChange={this.handleToChange}
placeholder={translate('end_date')}
ref={element => (this.toDateInput = element)}
expect(
shallow(<DateRangeInput onChange={jest.fn()} value={{ from: dateA, to: dateB }} />)
).toMatchSnapshot();
+
+ expect(
+ shallow(<DateRangeInput onChange={jest.fn()} minDate={dateA} maxDate={dateB} />)
+ ).toMatchSnapshot('with min/max');
+
+ expect(
+ shallow(
+ <DateRangeInput
+ onChange={jest.fn()}
+ minDate={dateA}
+ maxDate={dateB}
+ value={{ from: dateA, to: dateB }}
+ />
+ )
+ ).toMatchSnapshot('with min/max and value');
});
it('should change', () => {
/>
</div>
`;
+
+exports[`should render: with min/max 1`] = `
+<div
+ className="display-inline-flex-center"
+>
+ <DateInput
+ data-test="from"
+ maxDate={2018-02-05T00:00:00.000Z}
+ minDate={2018-01-17T00:00:00.000Z}
+ onChange={[Function]}
+ placeholder="start_date"
+ />
+ <span
+ className="note little-spacer-left little-spacer-right"
+ >
+ to_
+ </span>
+ <DateInput
+ data-test="to"
+ maxDate={2018-02-05T00:00:00.000Z}
+ minDate={2018-01-17T00:00:00.000Z}
+ onChange={[Function]}
+ placeholder="end_date"
+ />
+</div>
+`;
+
+exports[`should render: with min/max and value 1`] = `
+<div
+ className="display-inline-flex-center"
+>
+ <DateInput
+ currentMonth={2018-02-05T00:00:00.000Z}
+ data-test="from"
+ highlightTo={2018-02-05T00:00:00.000Z}
+ maxDate={2018-02-05T00:00:00.000Z}
+ minDate={2018-01-17T00:00:00.000Z}
+ onChange={[Function]}
+ placeholder="start_date"
+ value={2018-01-17T00:00:00.000Z}
+ />
+ <span
+ className="note little-spacer-left little-spacer-right"
+ >
+ to_
+ </span>
+ <DateInput
+ currentMonth={2018-01-17T00:00:00.000Z}
+ data-test="to"
+ highlightFrom={2018-01-17T00:00:00.000Z}
+ maxDate={2018-02-05T00:00:00.000Z}
+ minDate={2018-01-17T00:00:00.000Z}
+ onChange={[Function]}
+ placeholder="end_date"
+ value={2018-02-05T00:00:00.000Z}
+ />
+</div>
+`;
import { Store } from '../store/rootReducer';
import { L10nBundle } from './l10n';
+export enum AdminPageExtension {
+ GovernanceConsole = 'governance/views_console'
+}
+
export interface ExtensionStartMethod {
(params: ExtensionStartMethodParameter | string): ExtensionStartMethodReturnType;
}
#------------------------------------------------------------------------------
sessions.log_in=Log in
+#------------------------------------------------------------------------------
+#
+# Audit Logs
+#
+#------------------------------------------------------------------------------
+
+audit_logs.page=Audit Logs
+audit_logs.page.description.1=Audit logs help Administrators keep control and traceability of security related changes performed on the platform.
+audit_logs.page.description.2=Your instance is set to keep audit logs for {housekeeping}. You can change the number of days by updating your {link}.
+audit_logs.page.description.link=housekeeping policy
+
+audit_logs.houskeeping_policy.weekly=7 days
+audit_logs.houskeeping_policy.monthly=30 days
+audit_logs.houskeeping_policy.trimestrial=90 days
+audit_logs.houskeeping_policy.yearly=one year
+
+audit_logs.download=Download audit logs
+audit_logs.download_start.try_again=Try Again
+audit_logs.download_start.sentence.1=Your download should start shortly. For longer periods this might take some time.
+audit_logs.download_start.sentence.2=If the download doesn’t start after a few seconds, contact your administrator.
+audit_logs.download_start.sentence.3=Change your selection above to download additional audit logs.
+
+audit_logs.range_option.today=Today
+audit_logs.range_option.7days=7 days
+audit_logs.range_option.30days=30 days
+audit_logs.range_option.90days=90 days
+audit_logs.range_option.custom=Custom
+
#------------------------------------------------------------------------------
#
# HOTSPOTS