diff --git a/package.json b/package.json index fa5f6d8de..d8563288f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "start": "node ./src/bin/www", "start:dev": "nodemon --legacy-watch --inspect=0.0.0.0 ./src/bin/www", + "checkWebsiteStatus": "node src/server/services/checkWebsiteStatus.js", "webpack:dev": "webpack watch --color --progress --mode development", "webpack:build": "webpack build --node-env production", "webpack": "webpack build --color --progress --mode development", @@ -43,7 +44,8 @@ "generateCosineSquaredTestingData": "node -e 'require(\"./src/server/data/automatedTestingData\").generateCosineSquaredTestingData(2.5)'", "generateTestingData": "node -e 'require(\"./src/server/data/automatedTestingData\").generateTestingData()'", "testData": "node -e 'require(\"./src/server/data/automatedTestingData.js\").insertSpecialUnitsConversionsMetersGroups()'", - "webData": "node -e 'require(\"./src/server/data/websiteData.js\").insertWebsiteData()'" + "webData": "node -e 'require(\"./src/server/data/websiteData.js\").insertWebsiteData()'", + "addLogMsg": "node -e 'require(\"./src/server/services/addLogMsg.js\").addLogMsgToDB()'" }, "nodemonConfig": { "watch": [ @@ -135,4 +137,4 @@ "webpack": "~5.76.0", "webpack-cli": "~5.1.4" } -} +} \ No newline at end of file diff --git a/src/client/app/components/HeaderButtonsComponent.tsx b/src/client/app/components/HeaderButtonsComponent.tsx index e800c5895..244fce906 100644 --- a/src/client/app/components/HeaderButtonsComponent.tsx +++ b/src/client/app/components/HeaderButtonsComponent.tsx @@ -64,6 +64,7 @@ export default function HeaderButtonsComponent() { shouldCSVReadingsButtonDisabled: true, shouldUnitsButtonDisabled: true, shouldConversionsButtonDisabled: true, + shouldLogMsgButtonDisabled: true, shouldVisualUnitMapButtonDisabled: true, // Translated menu title that depend on whether logged in. menuTitle: '', @@ -101,6 +102,7 @@ export default function HeaderButtonsComponent() { shouldCSVReadingsButtonDisabled: pathname === '/csvReadings', shouldUnitsButtonDisabled: pathname === '/units', shouldConversionsButtonDisabled: pathname === '/conversions', + shouldLogMsgButtonDisabled: pathname === '/logmsg', shouldVisualUnitMapButtonDisabled: pathname === '/visual-unit' })); }, [pathname]); @@ -244,6 +246,13 @@ export default function HeaderButtonsComponent() { to="/admin"> + + + - + diff --git a/src/client/app/components/RouteComponent.tsx b/src/client/app/components/RouteComponent.tsx index cdf4389a3..15d46daf4 100644 --- a/src/client/app/components/RouteComponent.tsx +++ b/src/client/app/components/RouteComponent.tsx @@ -25,6 +25,7 @@ import RoleOutlet from './router/RoleOutlet'; import UnitsDetailComponent from './unit/UnitsDetailComponent'; import ErrorComponent from './router/ErrorComponent'; import { selectSelectedLanguage } from '../redux/slices/appStateSlice'; +import LogMsgComponent from './admin/LogMsgComponent'; import VisualUnitDetailComponent from './visual-unit/VisualUnitDetailComponent'; /** @@ -60,6 +61,8 @@ const router = createBrowserRouter([ { path: 'maps', element: }, { path: 'units', element: }, { path: 'users', element: }, + { path: 'logmsg', element: }, + { path: 'users', element: }, { path: 'visual-unit', element: } ] }, diff --git a/src/client/app/components/admin/LogMsgComponent.tsx b/src/client/app/components/admin/LogMsgComponent.tsx new file mode 100644 index 000000000..19bdfdf42 --- /dev/null +++ b/src/client/app/components/admin/LogMsgComponent.tsx @@ -0,0 +1,358 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import * as React from 'react'; +import * as moment from 'moment-timezone'; +import { orderBy } from 'lodash'; +import { + Alert, Button, Dropdown, DropdownItem, DropdownMenu, DropdownToggle, + FormFeedback, FormGroup, Input, Label, Modal, ModalBody, ModalHeader, + Pagination, PaginationItem, PaginationLink, Table +} from 'reactstrap'; +import DateRangePicker from '@wojtekmaj/react-daterange-picker'; +import { useAppSelector } from '../../redux/reduxHooks'; +import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; +import { logsApi } from '../../utils/api'; +import translate from '../../utils/translate'; +import { TimeInterval } from '../../../../common/TimeInterval'; +import { dateRangeToTimeInterval, timeIntervalToDateRange } from '../../utils/dateRangeCompatibility'; + +// number of log messages to display per page +const PER_PAGE = 20; + +enum LogTypes { + ERROR = 'ERROR', + INFO = 'INFO', + WARN = 'WARN', + DEBUG = 'DEBUG', + SILENT = 'SILENT' +} +// log types for filtering +const logTypes = Object.values(LogTypes); + +// initialize log message array to hold log messages +const initialLogs: any[] = []; + +/** + * React component that defines the log message page + * @returns LogMsgComponent element + */ +export default function LogMsgComponent() { + const locale = useAppSelector(selectSelectedLanguage); + // Log messages state + const [logs, setLogs] = React.useState(initialLogs); + // Log messages date range state + const [logDateRange, setLogDateRange] = React.useState(TimeInterval.unbounded()); + // Sort order for date column in the table + const [dateSortOrder, setDateSortOrder] = React.useState<'asc' | 'desc'>('asc'); + // Number of log messages to display + const [logLimit, setLogLimit] = React.useState(PER_PAGE); + // Current page state for pagination + const [currentPage, setCurrentPage] = React.useState(1); + // Showing all logs instead of paginated + const [showAllLogs, setShowAllLogs] = React.useState(false); + // Update button state + const [buttonAvailable, setButtonAvailable] = React.useState(false); + // Modal state for displaying full log message + const [modalOpen, setModalOpen] = React.useState(false); + // Log type and time to display in the modal header + const [modelHeader, setModelHeader] = React.useState(''); + // Full log message to display in the modal + const [modalLogMessage, setModalLogMessage] = React.useState(''); + // Selected log types for filtering in the update log + const [selectedUpdateLogTypes, setSelectedUpdateLogTypes] = React.useState(logTypes); + // "Select All Logs" button state for update log + const [selectAllUpdate, setSelectAllUpdate] = React.useState(true); + // Dropdown open state for log type in the update log for filtering + const [updateLogDropdown, setUpdateLogDropdown] = React.useState(false); + // Dropdown open state for log type in the header for filter + const [typeTableDropdown, setTypeTableDropdown] = React.useState(false); + // Selected log types for filtering in the table + const [selectedTableLogTypes, setSelectedTableLogTypes] = React.useState(logTypes); + // "Select All Logs" button state for table log + const [selectAllTable, setSelectAllTable] = React.useState(true); + + // Update the availability of the update button each time the selected log types, log limit, or date range changes + React.useEffect(() => { + setButtonAvailable(false); + }, [selectedUpdateLogTypes, logLimit, logDateRange]); + + // Open modal with the full log message + const handleLogMessageModal = (logType: string, logTime: string, logMessage: string) => { + setModelHeader(`[${logType}] ${moment.parseZone(logTime).format('LL LTS [(and ]SSS[ms)]')}`); + setModalLogMessage(logMessage); + setModalOpen(true); + }; + + // Handle checkbox change for log type in the table + const handleTableCheckboxChange = (logType: string) => { + if (selectedTableLogTypes.includes(logType)) { + // Remove log type if already selected + setSelectedTableLogTypes(selectedTableLogTypes.filter(type => type !== logType)); + } else { + // Add log type if not selected + setSelectedTableLogTypes([...selectedTableLogTypes, logType]); + } + }; + + // Handle checkbox change for log type in the update log + const handleUpdateCheckboxChange = (logType: string) => { + if (selectedUpdateLogTypes.includes(logType)) { + // Remove log type if already selected + setSelectedUpdateLogTypes(selectedUpdateLogTypes.filter(type => type !== logType)); + } else { + // Add log type if not selected + setSelectedUpdateLogTypes([...selectedUpdateLogTypes, logType]); + } + }; + + // React effect to keep track of the "Select All" checkbox state for the update log + React.useEffect(() => { + selectedUpdateLogTypes.length === logTypes.length ? setSelectAllUpdate(true) : setSelectAllUpdate(false); + }, [selectedUpdateLogTypes]); + + // React effect to keep track of the "Select All" checkbox state for the table + React.useEffect(() => { + selectedTableLogTypes.length === logTypes.length ? setSelectAllTable(true) : setSelectAllTable(false); + }, [selectedTableLogTypes]); + + // Handle "Select All" checkbox change in the table + const handleTableSelectAll = () => { + selectAllTable ? setSelectedTableLogTypes([]) : setSelectedTableLogTypes(logTypes); + setSelectAllTable(!selectAllTable); + }; + // Handle "Select All" checkbox change in the update log + const handleUpdateSelectAll = () => { + selectAllUpdate ? setSelectedUpdateLogTypes([]) : setSelectedUpdateLogTypes(logTypes); + setSelectAllUpdate(!selectAllUpdate); + }; + // Handle sorting of logs by date + const handleDateSort = () => { + const newDateSortOrder = dateSortOrder === 'asc' ? 'desc' : 'asc'; + const sortedLogs = orderBy(logs, ['logTime'], [newDateSortOrder]); + setDateSortOrder(newDateSortOrder); + setLogs(sortedLogs); + }; + + // Filter logs based on selected log types and date range + const paginatedLogs = showAllLogs + ? logs.filter(log => selectedTableLogTypes.includes(log.logType)) + : logs.filter(log => selectedTableLogTypes.includes(log.logType)) + .slice((currentPage - 1) * PER_PAGE, currentPage * PER_PAGE); + const totalPages = Math.ceil(logs.length / PER_PAGE); + + /** + * Handle showing the log table by fetching from the server + */ + async function handleShowLogTable() { + try { + // get log by date and type + const data = await logsApi.getLogsByDateRangeAndType( + logDateRange, selectedUpdateLogTypes.toString(), logLimit.toString()); + setLogs(data); + // reset pagination to first page after fetching new logs + setCurrentPage(1); + setButtonAvailable(true); + } catch (error) { + console.error(error); + } + } + + return ( + <> +

{translate('log.messages')}

+ + {/* Filter log messages by type, date range, and number of logs for fetching */} +
+ setUpdateLogDropdown(!updateLogDropdown)}> + {translate('log.type')} + + + + + {logTypes.map(logType => ( + + + + ))} + + + + + setLogDateRange(dateRangeToTimeInterval(e))} + minDate={new Date(1970, 0, 1)} + maxDate={new Date()} + // Formats Dates, and Calendar months base on locale + locale={locale} + calendarIcon={null} + calendarProps={{ defaultView: 'year' }} /> + + + + setLogLimit(e.target.valueAsNumber)} + invalid={!logLimit || logLimit < 1 || logLimit > 1000} + value={logLimit} + /> + + {translate('log.limit.required')} + + + +
+ + {/* Display log messages table */} + {logs.length > 0 ? + + + + + + + + + + {paginatedLogs.map((log, index) => ( + + + + + + ))} + +
+ setTypeTableDropdown(!typeTableDropdown)}> + {translate('log.type')} + + + + + {logTypes.map(logType => ( + + + + ))} + + + {translate('log.message')} + {translate('log.time')} {dateSortOrder === 'asc' ? '↑' : '↓'} +
{log.logType} handleLogMessageModal(log.logType, log.logTime, log.logMessage)} + > + {log.logMessage.length > 80 ? `${log.logMessage.slice(0, 80)} ...` : log.logMessage} + {moment.parseZone(log.logTime).format('LL LTS')}
+ : + {translate('no.logs')} + } + + {/* pagination */} + {!showAllLogs && logs.length !== 0 && + <> + + setCurrentPage(1)} /> + + setCurrentPage(currentPage - 1)} /> + + + {Array.from({ length: totalPages }, (_, index) => ( + + setCurrentPage(index + 1)}> + {index + 1} + + + ))} + + + setCurrentPage(currentPage + 1)} /> + + setCurrentPage(totalPages)} /> + + + } + + {/* Show all logs or in pages button */} + {logs.length > 0 && + } + + {/* Modal for displaying full log message */} + setModalOpen(!modalOpen)} centered> + setModalOpen(!modalOpen)}>{modelHeader} + + {modalLogMessage} + + + + ); +} + +const headerStyle: React.CSSProperties = { + textAlign: 'center' +}; +const bodyStyle: React.CSSProperties = { + textAlign: 'left' +}; +const titleStyle: React.CSSProperties = { + textAlign: 'center' +}; + +const tableStyle: React.CSSProperties = { + width: '90%', + margin: '1% auto' +}; + +const logFilterStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'center', + gap: '1.5%', + alignItems: 'center', + margin: 'auto 25%', + padding: '20px', + border: '2px solid lightgrey' +}; \ No newline at end of file diff --git a/src/client/app/components/admin/PreferencesComponent.tsx b/src/client/app/components/admin/PreferencesComponent.tsx index ffc2717cb..265244c48 100644 --- a/src/client/app/components/admin/PreferencesComponent.tsx +++ b/src/client/app/components/admin/PreferencesComponent.tsx @@ -3,11 +3,15 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { cloneDeep, isEqual } from 'lodash'; +import * as moment from 'moment'; import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { Button, Input } from 'reactstrap'; +import { Button, Input, FormFeedback } from 'reactstrap'; import { UnsavedWarningComponent } from '../UnsavedWarningComponent'; import { preferencesApi } from '../../redux/api/preferencesApi'; +import { + MIN_DATE, MIN_DATE_MOMENT, MAX_DATE, MAX_DATE_MOMENT, MAX_VAL, MIN_VAL, MAX_ERRORS +} from '../../redux/selectors/adminSelectors'; import { PreferenceRequestItem, TrueFalseType } from '../../types/items'; import { ChartTypes } from '../../types/redux/graph'; import { LanguageTypes } from '../../types/redux/i18n'; @@ -18,7 +22,6 @@ import TimeZoneSelect from '../TimeZoneSelect'; import { defaultAdminState } from '../../redux/slices/adminSlice'; -// TODO: Add warning for invalid data /** * @returns Preferences Component for Administrative use */ @@ -43,6 +46,51 @@ export default function PreferencesComponent() { setLocalAdminPref(cloneDeep(adminPreferences)); }; + // Functions for input validation and warnings. Each returns true if the user inputs invalid data into its field + // Need to be functions due to static reference. If they were booleans they wouldn't update when localAdminPref updates + const invalidFuncs = { + readingFreq: (): boolean => { + const frequency = moment.duration(localAdminPref.defaultMeterReadingFrequency); + return !frequency.isValid() || frequency.asSeconds() <= 0; + }, + minValue: (): boolean => { + const min = Number(localAdminPref.defaultMeterMinimumValue); + const max = Number(localAdminPref.defaultMeterMaximumValue); + return min < MIN_VAL || min > max; + }, + maxValue: (): boolean => { + const min = Number(localAdminPref.defaultMeterMinimumValue); + const max = Number(localAdminPref.defaultMeterMaximumValue); + return max > MAX_VAL || min > max; + }, + minDate: (): boolean => { + const minMoment = moment(localAdminPref.defaultMeterMinimumDate); + const maxMoment = moment(localAdminPref.defaultMeterMaximumDate); + return !minMoment.isValid() || !minMoment.isSameOrAfter(MIN_DATE_MOMENT) || !minMoment.isSameOrBefore(maxMoment); + }, + maxDate: (): boolean => { + const minMoment = moment(localAdminPref.defaultMeterMinimumDate); + const maxMoment = moment(localAdminPref.defaultMeterMaximumDate); + return !maxMoment.isValid() || !maxMoment.isSameOrBefore(MAX_DATE_MOMENT) || !maxMoment.isSameOrAfter(minMoment); + }, + readingGap: (): boolean => { return Number(localAdminPref.defaultMeterReadingGap) < 0; }, + + meterErrors: (): boolean => { + return Number(localAdminPref.defaultMeterMaximumErrors) < 0 + || Number(localAdminPref.defaultMeterMaximumErrors) > MAX_ERRORS; + }, + + warningFileSize: (): boolean => { + return Number(localAdminPref.defaultWarningFileSize) < 0 + || Number(localAdminPref.defaultWarningFileSize) > Number(localAdminPref.defaultFileSizeLimit); + }, + + fileSizeLimit: (): boolean => { + return Number(localAdminPref.defaultFileSizeLimit) < 0 + || Number(localAdminPref.defaultWarningFileSize) > Number(localAdminPref.defaultFileSizeLimit); + } + }; + return (
makeLocalChanges('defaultMeterReadingFrequency', e.target.value)} + invalid={invalidFuncs.readingFreq()} /> + + +

@@ -153,8 +205,14 @@ export default function PreferencesComponent() { type='number' value={localAdminPref.defaultMeterMinimumValue} onChange={e => makeLocalChanges('defaultMeterMinimumValue', e.target.value)} + min={MIN_VAL} + max={Number(localAdminPref.defaultMeterMaximumValue)} maxLength={50} + invalid={invalidFuncs.minValue()} /> + + +

@@ -164,8 +222,14 @@ export default function PreferencesComponent() { type='number' value={localAdminPref.defaultMeterMaximumValue} onChange={e => makeLocalChanges('defaultMeterMaximumValue', e.target.value)} + min={Number(localAdminPref.defaultMeterMinimumValue)} + max={MAX_VAL} maxLength={50} + invalid={invalidFuncs.maxValue()} /> + + +

@@ -176,7 +240,11 @@ export default function PreferencesComponent() { value={localAdminPref.defaultMeterMinimumDate} onChange={e => makeLocalChanges('defaultMeterMinimumDate', e.target.value)} placeholder='YYYY-MM-DD HH:MM:SS' + invalid={invalidFuncs.minDate()} /> + + +

@@ -187,7 +255,11 @@ export default function PreferencesComponent() { value={localAdminPref.defaultMeterMaximumDate} onChange={e => makeLocalChanges('defaultMeterMaximumDate', e.target.value)} placeholder='YYYY-MM-DD HH:MM:SS' + invalid={invalidFuncs.maxDate()} /> + + +

@@ -197,8 +269,13 @@ export default function PreferencesComponent() { type='number' value={localAdminPref.defaultMeterReadingGap} onChange={e => makeLocalChanges('defaultMeterReadingGap', e.target.value)} + min='0' maxLength={50} + invalid={invalidFuncs.readingGap()} /> + + +

@@ -208,8 +285,14 @@ export default function PreferencesComponent() { type='number' value={localAdminPref.defaultMeterMaximumErrors} onChange={e => makeLocalChanges('defaultMeterMaximumErrors', e.target.value)} + min='0' + max={MAX_ERRORS} maxLength={50} + invalid={invalidFuncs.meterErrors()} /> + + +

@@ -297,8 +380,14 @@ export default function PreferencesComponent() { type='number' value={localAdminPref.defaultWarningFileSize} onChange={e => makeLocalChanges('defaultWarningFileSize', e.target.value)} + min='0' + max={Number(localAdminPref.defaultFileSizeLimit)} maxLength={50} + invalid={invalidFuncs.warningFileSize()} /> + + +

@@ -308,8 +397,13 @@ export default function PreferencesComponent() { type='number' value={localAdminPref.defaultFileSizeLimit} onChange={e => makeLocalChanges('defaultFileSizeLimit', e.target.value)} + min={Number(localAdminPref.defaultWarningFileSize)} maxLength={50} + invalid={invalidFuncs.fileSizeLimit()} /> + + +

@@ -327,6 +421,7 @@ export default function PreferencesComponent() { onClick={discardChanges} disabled={!hasChanges} style={{ marginRight: '20px' }} + color='secondary' > {translate('discard.changes')} @@ -342,7 +437,8 @@ export default function PreferencesComponent() { showErrorNotification(translate('failed.to.submit.changes')); }) } - disabled={!hasChanges} + disabled={!hasChanges || Object.values(invalidFuncs).some(check => check())} + color='primary' > {translate('submit')} diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 91795e891..fe64e0f3c 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -8,9 +8,9 @@ const LocaleTranslationData = { "en": { "3D": "3D", + "4.weeks": "4 Weeks", "400": "400 Bad Request", "404": "404 Not Found", - "4.weeks": "4 Weeks", "action": "Action", "add.new.meters": "Add new meters", "admin.only": "Admin Only", @@ -18,16 +18,16 @@ const LocaleTranslationData = { "alphabetically": "Alphabetically", "area": "Area:", "area.but.no.unit": "You have entered a nonzero area but no area unit.", + "area.calculate.auto": "Calculate Group Area", "area.error": "Please enter a number for area", "area.normalize": "Normalize by Area", - "area.calculate.auto": "Calculate Group Area", "area.unit": "Area Unit:", "AreaUnitType.feet": "sq. feet", "AreaUnitType.meters": "sq. meters", "AreaUnitType.none": "no unit", - "ascending": "Ascending", - "as.meter.unit": "as meter unit", "as.meter.defaultgraphicunit": "as meter default graphic unit", + "as.meter.unit": "as meter unit", + "ascending": "Ascending", "bar": "Bar", "bar.interval": "Bar Interval", "bar.raw": "Cannot create bar graph on raw units such as temperature", @@ -53,8 +53,6 @@ const LocaleTranslationData = { "confirm.action": "Confirm Action", "contact.us": "Contact us", "conversion": "Conversion", - "conversions": "Conversions", - "ConversionType.conversion": "conversion", "conversion.bidirectional": "Bidirectional:", "conversion.create.destination.meter": "The destination cannot be a meter", "conversion.create.exists": "This conversion already exists", @@ -80,16 +78,14 @@ const LocaleTranslationData = { "conversion.successfully.create.conversion": "Successfully created a conversion.", "conversion.successfully.delete.conversion": "Successfully deleted conversion.", "conversion.successfully.edited.conversion": "Successfully edited conversion.", + "conversions": "Conversions", + "ConversionType.conversion": "conversion", "create.conversion": "Create a Conversion", "create.group": "Create a Group", "create.map": "Create a Map", - "create.user": "Create a User", "create.unit": "Create a Unit", + "create.user": "Create a User", "csv": "CSV", - "csvMeters": "CSV Meters", - "csvReadings": "CSV Readings", - "csv.file": "CSV File:", - "csv.file.error": "File must be in CSV format or GZIP format (.csv or .gz). ", "csv.clear.button": "Clear Form", "csv.common.param.gzip": "Gzip", "csv.common.param.header.row": "Header Row", @@ -97,6 +93,8 @@ const LocaleTranslationData = { "csv.download.size.limit": "Sorry you don't have permissions to download due to large number of points.", "csv.download.size.warning.size": "Total size of all files will be about (usually within 10% for large exports).", "csv.download.size.warning.verify": "Are you sure you want to download", + "csv.file": "CSV File:", + "csv.file.error": "File must be in CSV format or GZIP format (.csv or .gz). ", "csv.readings.param.create.meter": "Create Meter", "csv.readings.param.honor.dst": "Honor Daylight Savings Time", "csv.readings.param.meter.identifier": "Meter Identifier:", @@ -111,6 +109,8 @@ const LocaleTranslationData = { "csv.tab.readings": "Readings", "csv.upload.meters": "Upload Meters", "csv.upload.readings": "Upload Readings", + "csvMeters": "CSV Meters", + "csvReadings": "CSV Readings", "custom.value": "Custom value", "date.range": 'Date Range', "day": "Day", @@ -120,52 +120,52 @@ const LocaleTranslationData = { "default.area.normalize": "Normalize readings by area by default", "default.area.unit": "Default Area Unit", "default.bar.stacking": "Stack bars by default", - "default.graph.type": "Default Graph Type", - "default.graph.settings": "Default Graph Settings", - "defaultGraphicUnit": "Default Graphic Unit:", - "default.language": "Default Language", - "default.meter.reading.frequency": "Default meter reading frequency", - "default.warning.file.size": "Default Warning File Size", "default.file.size.limit": "Default File Size Limit", + "default.graph.settings": "Default Graph Settings", + "default.graph.type": "Default Graph Type", "default.help.url": "Documentation URL", - "default.time.zone": "Default Time Zone", - "default.meter.minimum.value": "Default meter minimum reading value check", + "default.language": "Default Language", + "default.meter.disable.checks": "Default meter disable checks", + "default.meter.maximum.date": "Default meter maximum reading date check", + "default.meter.maximum.errors": "Default maximum number of errors in meter reading", "default.meter.maximum.value": "Default meter maximum reading value check", "default.meter.minimum.date": "Default meter minimum reading date check", - "default.meter.maximum.date": "Default meter maximum reading date check", + "default.meter.minimum.value": "Default meter minimum reading value check", + "default.meter.reading.frequency": "Default meter reading frequency", "default.meter.reading.gap": "Default meter reading gap", - "default.meter.maximum.errors": "Default maximum number of errors in meter reading", - "default.meter.disable.checks": "Default meter disable checks", + "default.time.zone": "Default Time Zone", + "default.warning.file.size": "Default Warning File Size", + "defaultGraphicUnit": "Default Graphic Unit:", "delete.group": "Delete Group", "delete.map": "Delete Map", "delete.self": "Cannot delete your own Account.", "delete.user": "Delete User", "descending": "Descending", - "discard.changes": "Discard Changes", "disable": "Disable", + "discard.changes": "Discard Changes", "displayable": "Displayable:", - "DisplayableType.none": "none", - "DisplayableType.all": "all", "DisplayableType.admin": "admin", - "error.bounds": "Must be between {min} and {max}.", - "error.displayable": "Displayable will be set to false because no unit is selected.", - "error.displayable.meter": "Meter units will set displayable to none.", - "error.displayable.suffix.input": "Suffix input will set displayable to none.", - "error.greater": "Must be greater than {min}.", - "error.gps": "Latitude must be between -90 and 90, and Longitude must be between -180 and 180.", - "error.negative": "Cannot be negative.", - "error.required": "Required field.", - "error.unknown": "Oops! An error has occurred.", + "DisplayableType.all": "all", + "DisplayableType.none": "none", "edit": "Edit", - "edited": "edited", "edit.a.group": "Edit a Group", "edit.a.meter": "Edit a Meter", "edit.group": "Edit Group", "edit.meter": "Details/Edit Meter", "edit.unit": "Edit Unit", "edit.user": "Edit User", + "edited": "edited", "enable": "Enable", "error.bar": "Show error bars", + "error.bounds": "Must be between {min} and {max}.", + "error.displayable": "Displayable will be set to false because no unit is selected.", + "error.displayable.meter": "Meter units will set displayable to none.", + "error.displayable.suffix.input": "Suffix input will set displayable to none.", + "error.gps": "Latitude must be between -90 and 90, and Longitude must be between -180 and 180.", + "error.greater": "Must be greater than {min}.", + "error.negative": "Cannot be negative.", + "error.required": "Required field.", + "error.unknown": "Oops! An error has occurred.", "export.graph.data": "Export graph data", "export.raw.graph.data": "Export graph meter data", "failed.to.create.map": "Failed to create map", @@ -175,6 +175,7 @@ const LocaleTranslationData = { "failed.to.link.graph": "Failed to link graph", "failed.to.submit.changes": "Failed to submit changes", "false": "False", + "from.1.to.1000": "from 1 to 1000", "gps": "GPS: latitude, longitude", "graph": "Graph", "graph.settings": "Graph Settings", @@ -182,12 +183,12 @@ const LocaleTranslationData = { "group": "Group", "group.all.meters": "All Meters", "group.area.calculate": "Calculate Group Area", - "group.area.calculate.header": "Group Area will be set to ", + "group.area.calculate.error.group.unit": "No group area unit", "group.area.calculate.error.header": "The following meters were excluded from the sum because:", - "group.area.calculate.error.zero": ": area is unset or zero", - "group.area.calculate.error.unit": ": nonzero area but no area unit", "group.area.calculate.error.no.meters": "No meters in group", - "group.area.calculate.error.group.unit": "No group area unit", + "group.area.calculate.error.unit": ": nonzero area but no area unit", + "group.area.calculate.error.zero": ": area is unset or zero", + "group.area.calculate.header": "Group Area will be set to ", "group.create.nounit": "The default graphic unit was changed to no unit from ", "group.delete.group": "Delete Group", "group.delete.issue": "is contained in the following groups and cannot be deleted", @@ -205,14 +206,14 @@ const LocaleTranslationData = { "group.hidden": "At least one group is not visible to you", "group.input.error": "Input invalid so group not created or edited.", "group.name.error": "Please enter a valid name: (must have at least one character that is not a space)", - "groups": "Groups", "group.successfully.create.group": "Successfully created a group.", "group.successfully.edited.group": "Successfully edited group.", + "groups": "Groups", "groups.select": "Select Groups", "has.no.data": "has no current data", "has.used": "has used", - "header.pages": "Pages", "header.options": "Options", + "header.pages": "Pages", "help": "Help", "help.admin.conversioncreate": "This page allows admins to create conversions. Please visit {link} for further details and information.", "help.admin.conversionedit": "This page allows admins to edit conversions. Please visit {link} for further details and information.", @@ -232,14 +233,13 @@ const LocaleTranslationData = { "help.admin.users": "This page allows admins to view and edit users. Please visit {link} for further details and information.", "help.csv.meters": "This page allows admins to upload meters via a CSV file. Please visit {link} for further details and information.", "help.csv.readings": "This page allows certain users to upload readings via a CSV file. Please visit {link} for further details and information.", + "help.groups.area.calculate": "This will sum together the area of all meters in this group with a nonzero area with an area unit. It will ignore any meters which have no area or area unit. If this group has no area unit, it will do nothing.", "help.groups.groupdetails": "This page shows detailed information on a group. Please visit {link} for further details and information.", "help.groups.groupview": "This page shows information on groups. Please visit {link} for further details and information.", - "help.groups.area.calculate": "This will sum together the area of all meters in this group with a nonzero area with an area unit. It will ignore any meters which have no area or area unit. If this group has no area unit, it will do nothing.", "help.home.area.normalize": "Toggles normalization by area. Meters/Groups without area will be hidden. Please visit {link} for further details and information.", "help.home.bar.days.tip": "Allows user to select the desired number of days for each bar. Please see {link} for further details and information.", "help.home.bar.interval.tip": "Selects the time interval (Day, Week or 4 Weeks) for each bar. Please see {link} for further details and information.", "help.home.bar.stacking.tip": "Bars stack on top of each other. Please see {link} for further details and information.", - "help.home.map.interval.tip": "Selects the time interval (the last Day, Week or 4 Weeks) for map corresponding to bar's time interval. Please see {link} for further details and information.", "help.home.chart.plotly.controls": "These controls are provided by Plotly, the graphics package used by OED. You generally do not need them but they are provided in case you want that level of control. Note that some of these options may not interact nicely with OED features. See Plotly documentation at {link}.", "help.home.chart.redraw.restore": "OED automatically averages data when necessary so the graphs have a reasonable number of points. If you use the controls under the graph to scroll and/or zoom, you may find the resolution at this averaged level is not what you desire. Clicking the \"Redraw\" button will have OED recalculate the averaging and bring in higher resolution for the number of points it displays. If you want to restore the graph to the full range of dates, then click the \"Restore\" button. Please visit {link} for further details and information.", "help.home.chart.select": "Any graph type can be used with any combination of groups and meters. Line graphs show the usage (e.g., kW) vs. time. You can zoom and scroll with the controls right below the graph. Bar shows the total usage (e.g., kWh) for the time frame of each bar where you can control the time frame. Compare allows you to see the current usage vs. the usage in the last previous period for a day, week and four weeks. Map graphs show a spatial image of each meter where the circle size is related to four weeks of usage. 3D graphs show usage vs. day vs. hours in the day. Clicking on one of the choices renders that graphic. Please visit {link} for further details and information.", @@ -248,6 +248,7 @@ const LocaleTranslationData = { "help.home.error.bar": "Toggle error bars with min and max value. Please visit {link} for further details and information.", "help.home.export.graph.data": "With the \"Export graph data\" button, one can export the data for the graph when viewing either a line or bar graphic. The zoom and scroll feature on the line graph allows you to control the time frame of the data exported. The \"Export graph data\" button gives the data points for the graph and not the original meter data. The \"Export graph meter data\" gives the underlying meter data (line graphs only). Please visit {link} for further details and information on when meter data export is allowed.", "help.home.history": "Allows the user to navigate through the recent history of graphs. Please visit {link} for further details and information.", + "help.home.map.interval.tip": "Selects the time interval (the last Day, Week or 4 Weeks) for map corresponding to bar's time interval. Please see {link} for further details and information.", "help.home.navigation": "The \"Graph\" button goes to the graphic page, the \"Pages\" dropdown allows navigation to information pages, the \"Options\" dropdown allows selection of language, hide options and login/out and the \"Help\" button goes to the help pages. See help on the dropdown menus or the linked pages for further information.", "help.home.readings.per.day": "The number of readings shown for each day in a 3D graphic. Please visit {link} for further details and information.", "help.home.select.dateRange": "Select date range used in graphic display. For 3D graphic must be one year or less. Please visit {link} for further details and information.", @@ -276,6 +277,7 @@ const LocaleTranslationData = { "input.gps.coords.second": "in this format -> latitude,longitude", "input.gps.range": "Invalid GPS coordinate, latitude must be an integer between -90 and 90, longitude must be an integer between -180 and 180. You input: ", "insufficient.readings": "Insufficient readings data to process comparison for ", + "invalid.input": "Invalid input", "invalid.number": "Please submit a valid number (between 0 and 2.0)", "invalid.token.login": "Token has expired. Please log in again.", "invalid.token.login.admin": "Token has expired. Please log in again to view this page.", @@ -290,18 +292,22 @@ const LocaleTranslationData = { "less.energy": "less energy", "line": "Line", "log.in": "Log in", + "log.limit.required": "Number of logs to display must be within 1 to 1000", + "log.message": "Log Message", + "log.messages": "Log Messages", "log.out": "Log out", + "log.time": "Log Time", + "log.type": "Log Type", "login.failed": "Failed logging in", "login.success": "Login Successful", "logo": "Logo", "manage": "Manage", "map": "Map", - "maps": "Maps", + "map.bad.digita": "Greater than 360, please change angle to a number between 0 and 360", + "map.bad.digitb": "Less than 0, please change angle to a number between 0 and 360", "map.bad.load": "Map image file needed", "map.bad.name": "Map name needed", "map.bad.number": "Not a number, please change angle to a number between 0 and 360", - "map.bad.digita": "Greater than 360, please change angle to a number between 0 and 360", - "map.bad.digitb": "Less than 0, please change angle to a number between 0 and 360", "map.calibrate": "Calibrate", "map.calibration": "Calibration status", "map.circle.size": "Map Circle Size", @@ -325,39 +331,35 @@ const LocaleTranslationData = { "map.notify.calibration.needed": "Calibration needed before display", "map.unavailable": "There's not an available map", "map.upload.new.file": "Redo", + "maps": "Maps", "max": "max", "menu": "Menu", "meter": "Meter", - "meters": "Meters", "meter.create": "Create a Meter", "meter.cumulative": "Cumulative:", "meter.cumulativeReset": "Cumulative Reset:", "meter.cumulativeResetEnd": "Cumulative Reset End:", "meter.cumulativeResetStart": "Cumulative Reset Start:", - "meter.edit.displayable.warning": "is not displayable but is used by the following displayable groups:", + "meter.disableChecks": "Disable Checks", "meter.edit.displayable.verify": "Given the group(s) listed above, do you want to cancel this change (click Cancel) or continue (click OK)?", + "meter.edit.displayable.warning": "is not displayable but is used by the following displayable groups:", "meter.enabled": "Updates:", "meter.endOnlyTime": "Only End Times:", "meter.endTimeStamp": "End Time Stamp:", - "meter.minVal": "Minimum Reading Value Check", - "meter.maxVal": "Maximum Reading Value Check", - "meter.minDate": "Minimum Reading Date Check", - "meter.maxDate": "Maximum Reading Date Check", - "meter.maxError": "Maximum Number of Errors Check", - "meter.disableChecks": "Disable Checks", "meter.failed.to.create.meter": "Failed to create a meter with message: ", "meter.failed.to.edit.meter": "Failed to edit meter with message: ", "meter.hidden": "At least one meter is not visible to you", "meter.id": "ID", "meter.input.error": "Input invalid so meter not created or edited.", - "meter.unit.change.requires": "needs to be changed before changing this unit's type", - "meter.unitName": "Unit:", - "meter.url": "URL:", "meter.is.displayable": "Display Enabled", "meter.is.enabled": "Updates Enabled", "meter.is.not.displayable": "Display Disabled", "meter.is.not.enabled": "Updates Disabled", - "meter.unit.is.not.editable": "This meter's unit cannot be changed and was put back to the original value because: ", + "meter.maxDate": "Maximum Reading Date Check", + "meter.maxError": "Maximum Number of Errors Check", + "meter.maxVal": "Maximum Reading Value Check", + "meter.minDate": "Minimum Reading Date Check", + "meter.minVal": "Minimum Reading Value Check", "meter.previousEnd": "Previous End Time Stamp:", "meter.reading": "Reading:", "meter.readingDuplication": "Reading Duplication:", @@ -368,19 +370,26 @@ const LocaleTranslationData = { "meter.startTimeStamp": "Start Time Stamp:", "meter.successfully.create.meter": "Successfully created a meter.", "meter.successfully.edited.meter": "Successfully edited meter.", - "meter.timeSort": "Time Sort:", "meter.time.zone": "Time Zone:", + "meter.timeSort": "Time Sort:", "meter.type": "Type:", - "minute": "Minute", + "meter.unit.change.requires": "needs to be changed before changing this unit's type", + "meter.unit.is.not.editable": "This meter's unit cannot be changed and was put back to the original value because: ", + "meter.unitName": "Unit:", + "meter.url": "URL:", + "meters": "Meters", "min": "min", + "minute": "Minute", "more.energy": "more energy", "more.options": "More Options", "name": "Name:", "navigation": "Navigation", "need.more.points": "Need more points", "no": "no", - "note": "Note: ", "no.data.in.range": "No Data In Date Range", + "no.logs": "No logs to display. Please select another log type or data range", + "note": "Note: ", + "num.logs.display": "Number of logs to display", "oed": "Open Energy Dashboard", "oed.description": "Open Energy Dashboard is an independent open source project. ", "oed.version": "OED version ", @@ -403,8 +412,8 @@ const LocaleTranslationData = { "rate.limit.error.first": "You have been rate limited by your OED site", "rate.limit.error.second": "We suggest you try these in this order:", "reading": "Reading:", - "redo.cik.and.refresh.db.views": "Processing changes. This may take a while.", "readings.per.day": "Readings per Day", + "redo.cik.and.refresh.db.views": "Processing changes. This may take a while.", "redraw": "Redraw", "refresh.page.first": "Click the Refresh this page' button below to try again", "refresh.page.second": "If you keep returning to this page wait longer and click 'Refresh this page' button", @@ -418,26 +427,28 @@ const LocaleTranslationData = { "save.meter.edits": "Save meter edits", "save.role.changes": "Save role changes", "second": "Second", + "select.all": "Select All", "select.groups": "Select Groups", "select.map": "Select Map", "select.meter": "Select Meter", - "select.meter.type": "Select Meter Type", "select.meter.group": "Select meter or group to graph", + "select.meter.type": "Select Meter Type", "select.meters": "Select Meters", "select.unit": "Select Unit", "show": "Show", + "show.all.logs": "Show All Logs ", "show.grid": "Show grid", "show.options": "Show options", + "show.in.pages": "Show in Pages", "site.settings": "Site Settings", "site.title": "Site Title", "sort": "Sort Order", "submit": "Submit", - "submitting": "submitting", "submit.changes": "Submit changes", "submit.new.user": "Submit new user", + "submitting": "submitting", "the.unit.of.meter": "The unit of meter", "this.four.weeks": "These four weeks", - "timezone.no": "No timezone", "this.week": "This week", "threeD.area.incompatible": "
is incompatible
with area normalization", "threeD.date": "Date", @@ -449,28 +460,23 @@ const LocaleTranslationData = { "threeD.y.axis.label": "Days of Calendar Year", "TimeSortTypes.decreasing": "decreasing", "TimeSortTypes.increasing": "increasing", + "timezone.no": "No timezone", "today": "Today", "toggle.link": "Toggle chart link", - "toggle.options" : "Toggle options", + "toggle.options": "Toggle options", "total": "total", "true": "True", "TrueFalseType.false": "no", "TrueFalseType.true": "yes", "undefined": "undefined", "unit": "Unit", - "UnitRepresentType.quantity": "quantity", - "UnitRepresentType.flow": "flow", - "UnitRepresentType.raw": "raw", - "UnitType.unit": "unit", - "UnitType.meter": "meter", - "UnitType.suffix": "suffix", "unit.delete.failure": "Failed to deleted unit with error: ", "unit.delete.success": "Successfully deleted unit", "unit.delete.unit": "Delete Unit", "unit.destination.error": "as the destination unit", - "unit.dropdown.displayable.option.none": "None", - "unit.dropdown.displayable.option.all": "All", "unit.dropdown.displayable.option.admin": "admin", + "unit.dropdown.displayable.option.all": "All", + "unit.dropdown.displayable.option.none": "None", "unit.failed.to.create.unit": "Failed to create a unit.", "unit.failed.to.delete.unit": "Delete cannot be done because this unit is used by the following", "unit.failed.to.edit.unit": "Failed to edit unit.", @@ -486,8 +492,14 @@ const LocaleTranslationData = { "unit.suffix": "Suffix:", "unit.type.of.unit": "Type of Unit:", "unit.type.of.unit.suffix": "Added suffix will set type of unit to suffix", + "UnitRepresentType.flow": "flow", + "UnitRepresentType.quantity": "quantity", + "UnitRepresentType.raw": "raw", "units": "Units", "units.conversion.page.title": "Units and Conversions Visual Graphics", + "UnitType.meter": "meter", + "UnitType.suffix": "suffix", + "UnitType.unit": "unit", "unsaved.failure": "Changes failed to save", "unsaved.success": "Changes saved", "unsaved.warning": "You have unsaved change(s). Are you sure you want to leave?", @@ -502,13 +514,13 @@ const LocaleTranslationData = { "upload.readings.csv": "Upload readings CSV file", "used.so.far": "used so far", "used.this.time": "used this time", - "username": "Username:", "user.delete.confirm": "Delete the user: ", "user.password.edit": "Only enter password to update password", "user.password.length": "Password must be a minimum of 8 characters", "user.password.mismatch": "Passwords do not match", "user.role": "Role: ", "user.role.select": "Select Role", + "username": "Username:", "users": "Users", "users.failed.to.create.user": "Failed to create the user: ", "users.failed.to.delete.user": "Failed to delete the user: ", @@ -519,20 +531,20 @@ const LocaleTranslationData = { "uses": "uses", "view.groups": "View Groups", "visit": " or visit our ", - "visual.unit": "Units Visual Graphics", - "visual.input.units.graphic": "Input Units Visual Graphic", "visual.analyzed.units.graphic": "Analyzed Units Visual Graphic", + "visual.input.units.graphic": "Input Units Visual Graphic", + "visual.unit": "Units Visual Graphics", "website": "website", "week": "Week", "yes": "yes", "yesterday": "Yesterday", - "you.cannot.create.a.cyclic.group": "You cannot create a cyclic group" + "you.cannot.create.a.cyclic.group": "You cannot create a cyclic group", }, "fr": { "3D": "3D", + "4.weeks": "4 Semaines", "400": "400 Bad Request\u{26A1}", "404": "404 Introuvable", - "4.weeks": "4 Semaines", "action": "Action\u{26A1}", "add.new.meters": "Ajouter de Nouveaux Mètres", "admin.only": "Uniquement pour Les Administrateurs", @@ -540,16 +552,16 @@ const LocaleTranslationData = { "alphabetically": "Alphabétiquement", "area": "Région:", "area.but.no.unit": "You have entered a nonzero area but no area unit.\u{26A1}", + "area.calculate.auto": "Calculate Group Area\u{26A1}", "area.error": "Please enter a number for area\u{26A1}", "area.normalize": "Normalize by Area\u{26A1}", - "area.calculate.auto": "Calculate Group Area\u{26A1}", "area.unit": "Area Unit:\u{26A1}", "AreaUnitType.feet": "pieds carrés", "AreaUnitType.meters": "mètre carré", "AreaUnitType.none": "no unit\u{26A1}", - "ascending": "Ascendant", - "as.meter.unit": "as meter unit\u{26A1}", "as.meter.defaultgraphicunit": "as meter default graphic unit\u{26A1}", + "as.meter.unit": "as meter unit\u{26A1}", + "ascending": "Ascendant", "bar": "Bande", "bar.interval": "Intervalle du Diagramme à Bandes", "bar.raw": "Cannot create bar graph on raw units such as temperature\u{26A1}", @@ -575,8 +587,6 @@ const LocaleTranslationData = { "confirm.action": "Confirm Action\u{26A1}", "contact.us": "Contactez nous", "conversion": "Conversion\u{26A1}", - "conversions": "Conversions\u{26A1}", - "ConversionType.conversion": "conversion\u{26A1}", "conversion.bidirectional": "Bidirectional:\u{26A1}", "conversion.create.destination.meter": "The destination cannot be a meter\u{26A1}", "conversion.create.exists": "This conversion already exists\u{26A1}", @@ -602,16 +612,14 @@ const LocaleTranslationData = { "conversion.successfully.create.conversion": "Successfully created a conversion.\u{26A1}", "conversion.successfully.delete.conversion": "Successfully deleted conversion.\u{26A1}", "conversion.successfully.edited.conversion": "Successfully edited conversion.\u{26A1}", + "conversions": "Conversions\u{26A1}", + "ConversionType.conversion": "conversion\u{26A1}", "create.conversion": "Create a Conversion\u{26A1}", "create.group": "Créer un Groupe", "create.map": "Créer une carte", "create.unit": "Create a Unit\u{26A1}", "create.user": "Créer un utilisateur", "csv": "CSV", - "csvMeters": "CSV Meters\u{26A1}", - "csvReadings": "CSV Readings\u{26A1}", - "csv.file": "Fichier CSV:", - "csv.file.error": "Le fichier doit être au format CSV ou GZIP (.csv ou .gz). ", "csv.clear.button": "Forme claire", "csv.common.param.gzip": "Gzip", "csv.common.param.header.row": "Ligne d'en-tête", @@ -619,6 +627,8 @@ const LocaleTranslationData = { "csv.download.size.limit": "Sorry you don't have permissions to download due to large number of points.\u{26A1}", "csv.download.size.warning.size": "Total size of all files will be about (usually within 10% for large exports).\u{26A1}", "csv.download.size.warning.verify": "Are you sure you want to download\u{26A1}", + "csv.file": "Fichier CSV:", + "csv.file.error": "Le fichier doit être au format CSV ou GZIP (.csv ou .gz). ", "csv.readings.param.create.meter": "Créer un compteur", "csv.readings.param.honor.dst": "Honor Daylight Savings Time\u{26A1}", "csv.readings.param.meter.identifier": "Identifiant du compteur:", @@ -633,6 +643,8 @@ const LocaleTranslationData = { "csv.tab.readings": "Lectures", "csv.upload.meters": "Téléverser Mètres", "csv.upload.readings": "Téléverser Lectures", + "csvMeters": "CSV Meters\u{26A1}", + "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Custom value\u{26A1}", "date.range": 'Plage de dates', "day": "Journée", @@ -642,52 +654,52 @@ const LocaleTranslationData = { "default.area.normalize": "Normalize readings by area by default\u{26A1}", "default.area.unit": "Default Area Unit\u{26A1}", "default.bar.stacking": "Stack bars by default\u{26A1}", - "default.graph.type": "Type du Diagramme par Défaut", - "default.graph.settings": "Default Graph Settings\u{26A1}", - "defaultGraphicUnit": "Default Graphic Unit:\u{26A1}", - "default.language": "Langue par Défaut", - "default.meter.reading.frequency": "Default meter reading frequency\u{26A1}", - "default.warning.file.size": "Taille du fichier d'avertissement par défaut", "default.file.size.limit": "Limite de taille de fichier par défaut", + "default.graph.settings": "Default Graph Settings\u{26A1}", + "default.graph.type": "Type du Diagramme par Défaut", "default.help.url": "Documentation URL\u{26A1}", - "default.time.zone": "Zona Horaria Predeterminada", - "default.meter.minimum.value": "Default meter minimum reading value check\u{26A1}", + "default.language": "Langue par Défaut", + "default.meter.disable.checks": "Default meter disable checks\u{26A1}", + "default.meter.maximum.date": "Default meter maximum reading date check\u{26A1}", + "default.meter.maximum.errors": "Default maximum number of errors in meter reading\u{26A1}", "default.meter.maximum.value": "Default meter maximum reading value check\u{26A1}", "default.meter.minimum.date": "Default meter minimum reading date check\u{26A1}", - "default.meter.maximum.date": "Default meter maximum reading date check\u{26A1}", + "default.meter.minimum.value": "Default meter minimum reading value check\u{26A1}", + "default.meter.reading.frequency": "Default meter reading frequency\u{26A1}", "default.meter.reading.gap": "Default meter reading gap\u{26A1}", - "default.meter.maximum.errors": "Default maximum number of errors in meter reading\u{26A1}", - "default.meter.disable.checks": "Default meter disable checks\u{26A1}", + "default.time.zone": "Zona Horaria Predeterminada", + "default.warning.file.size": "Taille du fichier d'avertissement par défaut", + "defaultGraphicUnit": "Default Graphic Unit:\u{26A1}", "delete.group": "Supprimer le Groupe", "delete.map": "Supprimer la carte", "delete.self": "Impossible de supprimer votre propre compte.", "delete.user": "Supprimer l'utilisateur", "descending": "Descendant", - "discard.changes": "Annuler les Modifications", "disable": "Désactiver", + "discard.changes": "Annuler les Modifications", "displayable": "Affichable:", - "DisplayableType.none": "none\u{26A1}", - "DisplayableType.all": "all\u{26A1}", "DisplayableType.admin": "admin\u{26A1}", - "error.bounds": "Must be between {min} and {max}.\u{26A1}", - "error.displayable": "Displayable will be set to false because no unit is selected.\u{26A1}", - "error.displayable.meter": "Meter units will set displayable to none.\u{26A1}", - "error.displayable.suffix.input": "Suffix input will set displayable to none.\u{26A1}", - "error.greater": "Must be greater than {min}.\u{26A1}", - "error.gps": "Latitude must be between -90 and 90, and Longitude must be between -180 and 180.\u{26A1}", - "error.negative": "Cannot be negative.\u{26A1}", - "error.required": "Required field.\u{26A1}", - "error.unknown": "Oops! An error has occurred.\u{26A1}", + "DisplayableType.all": "all\u{26A1}", + "DisplayableType.none": "none\u{26A1}", "edit": "Modifier", - "edited": "édité", "edit.a.group": "Modifier le Groupe", "edit.a.meter": "Modifier le Métre", "edit.group": "Modifier Groupe", "edit.meter": "Details/Modifier Métre\u{26A1}", "edit.unit": "Edit Unit\u{26A1}", "edit.user": "Modifier l'utilisateur", + "edited": "édité", "enable": "Activer", "error.bar": "Show error bars\u{26A1}", + "error.bounds": "Must be between {min} and {max}.\u{26A1}", + "error.displayable": "Displayable will be set to false because no unit is selected.\u{26A1}", + "error.displayable.meter": "Meter units will set displayable to none.\u{26A1}", + "error.displayable.suffix.input": "Suffix input will set displayable to none.\u{26A1}", + "error.gps": "Latitude must be between -90 and 90, and Longitude must be between -180 and 180.\u{26A1}", + "error.greater": "Must be greater than {min}.\u{26A1}", + "error.negative": "Cannot be negative.\u{26A1}", + "error.required": "Required field.\u{26A1}", + "error.unknown": "Oops! An error has occurred.\u{26A1}", "export.graph.data": "Exporter les données du diagramme", "export.raw.graph.data": "Export graph meter data\u{26A1}", "failed.to.create.map": "Échec de la création d'une carte", @@ -697,6 +709,7 @@ const LocaleTranslationData = { "failed.to.link.graph": "Échec de lier le graphique", "failed.to.submit.changes": "Échec de l'envoi des modifications", "false": "Faux", + "from.1.to.1000": "from 1 to 1000\u{26A1}", "gps": "GPS: latitude, longitude\u{26A1}", "graph": "Graphique", "graph.settings": "Graph Settings\u{26A1}", @@ -704,12 +717,12 @@ const LocaleTranslationData = { "group": "Groupe", "group.all.meters": "Tous les compteurs", "group.area.calculate": "Calculate Group Area\u{26A1}", - "group.area.calculate.header": "Group Area will be set to \u{26A1}", + "group.area.calculate.error.group.unit": "No group area unit\u{26A1}", "group.area.calculate.error.header": "The following meters were excluded from the sum because:\u{26A1}", - "group.area.calculate.error.zero": ": area is unset or zero\u{26A1}", - "group.area.calculate.error.unit": ": nonzero area but no area unit\u{26A1}", "group.area.calculate.error.no.meters": "No meters in group\u{26A1}", - "group.area.calculate.error.group.unit": "No group area unit\u{26A1}", + "group.area.calculate.error.unit": ": nonzero area but no area unit\u{26A1}", + "group.area.calculate.error.zero": ": area is unset or zero\u{26A1}", + "group.area.calculate.header": "Group Area will be set to \u{26A1}", "group.create.nounit": "The default graphic unit was changed to no unit from \u{26A1}", "group.delete.group": "Delete Group\u{26A1}", "group.delete.issue": "is contained in the following groups and cannot be deleted\u{26A1}", @@ -727,14 +740,14 @@ const LocaleTranslationData = { "group.hidden": "At least one group is not visible to you\u{26A1}", "group.input.error": "Input invalid so group not created or edited.\u{26A1}", "group.name.error": "Please enter a valid name: (must have at least one character that is not a space)\u{26A1}", - "groups": "Groupes", "group.successfully.create.group": "Successfully created a group.\u{26A1}", "group.successfully.edited.group": "Successfully edited group.\u{26A1}", + "groups": "Groupes", "groups.select": "Sélectionnez des Groupes", "has.no.data": "has no current data\u{26A1}", "has.used": "a utilisé", - "header.pages": "Pages\u{26A1}", "header.options": "Options\u{26A1}", + "header.pages": "Pages\u{26A1}", "help": "Help\u{26A1}", "help.admin.conversioncreate": "This page allows admins to create conversions. Please visit {link} for further details and information.\u{26A1}", "help.admin.conversionedit": "This page allows admins to edit conversions. Please visit {link} for further details and information.\u{26A1}", @@ -754,14 +767,13 @@ const LocaleTranslationData = { "help.admin.users": "This page allows admins to view and edit users. Please visit {link} for further details and information.\u{26A1}", "help.csv.meters": "Cette page permet aux administrateurs de téléverser des mètres via un fichier CSV. Veuillez visiter {link} pour plus de détails et d'informations.\u{26A1}", "help.csv.readings": "Cette page permet à certains utilisateurs de télécharger des lectures via un fichier CSV. Veuillez visiter {link} pour plus de détails et d'informations.\u{26A1}", + "help.groups.area.calculate": "This will sum together the area of all meters in this group with a nonzero area with an area unit. It will ignore any meters which have no area or area unit. If this group has no area unit, it will do nothing.\u{26A1}", "help.groups.groupdetails": "This page shows detailed information on a group. Please visit {link} for further details and information.\u{26A1}", "help.groups.groupview": "This page shows information on groups. Please visit {link} for further details and information.\u{26A1}", - "help.groups.area.calculate": "This will sum together the area of all meters in this group with a nonzero area with an area unit. It will ignore any meters which have no area or area unit. If this group has no area unit, it will do nothing.\u{26A1}", "help.home.area.normalize": "Toggles normalization by area. Meters/Groups without area will be hidden. Please visit {link} for further details and information.\u{26A1}", "help.home.bar.days.tip": "Allows user to select the desired number of days for each bar. Please see {link} for further details and information.\u{26A1}", "help.home.bar.interval.tip": "Selects the time interval (Day, Week or 4 Weeks) for each bar. Please see {link} for further details and information.\u{26A1}", "help.home.bar.stacking.tip": "Bars stack on top of each other. Please see {link} for further details and information.\u{26A1}", - "help.home.map.interval.tip": "for map corresponding to bar's time interval. Please see {link} for further details and information.\u{26A1}", "help.home.chart.plotly.controls": "These controls are provided by Plotly, the graphics package used by OED. You generally do not need them but they are provided in case you want that level of control. Note that some of these options may not interact nicely with OED features. See Plotly documentation at {link}.\u{26A1}", "help.home.chart.redraw.restore": "OED automatically averages data when necessary so the graphs have a reasonable number of points. If you use the controls under the graph to scroll and/or zoom, you may find the resolution at this averaged level is not what you desire. Clicking the \"Redraw\" button will have OED recalculate the averaging and bring in higher resolution for the number of points it displays. If you want to restore the graph to the full range of dates, then click the \"Restore\" button. Please visit {link} for further details and information.\u{26A1}", "help.home.chart.select": "for the time frame of each bar where you can control the time frame. Compare allows you to see the current usage vs. the usage in the last previous period for a day, week and four weeks. Map graphs show a spatial image of each meter where the circle size is related to four weeks of usage. 3D graphs show usage vs. day vs. hours in the day. Clicking on one of the choices renders that graphic. Please visit {link} for further details and information.\u{26A1}", @@ -770,6 +782,7 @@ const LocaleTranslationData = { "help.home.error.bar": "Toggle error bars with min and max value. Please visit {link} for further details and information.\u{26A1}", "help.home.export.graph.data": "With the \"Export graph data\" button, one can export the data for the graph when viewing either a line or bar graphic. The zoom and scroll feature on the line graph allows you to control the time frame of the data exported. The \"Export graph data\" button gives the data points for the graph and not the original meter data. The \"Export graph meter data\" gives the underlying meter data (line graphs only). Please visit {link} for further details and information on when meter data export is allowed.\u{26A1}", "help.home.history": "Permet à l'utilisateur de naviguer dans l'historique récent des graphiques. Veuillez visiter {link} pour plus de détails et d'informations.", + "help.home.map.interval.tip": "for map corresponding to bar's time interval. Please see {link} for further details and information.\u{26A1}", "help.home.navigation": "The \"Graph\" button goes to the graphic page, the \"Pages\" dropdown allows navigation to information pages, the \"Options\" dropdown allows selection of language, hide options and login/out and the \"Help\" button goes to the help pages. See help on the dropdown menus or the linked pages for further information.\u{26A1}", "help.home.readings.per.day": "The number of readings shown for each day in a 3D graphic. Please visit {link} for further details and information.\u{26A1}", "help.home.select.dateRange": "Select date range used in graphic display. For 3D graphic must be one year or less. Please visit {link} for further details and information.\u{26A1}", @@ -798,6 +811,7 @@ const LocaleTranslationData = { "input.gps.coords.second": "in this format -> latitude,longitude\u{26A1}", "input.gps.range": "Coordonnée GPS invalide, la latitude doit être un nombre entier entre -90 et 90, la longitude doit être un nombre entier entre -180 et 180. You input: \u{26A1}", "insufficient.readings": "Données de lectures insuffisantes pour la comparaison de processus pour ", + "invalid.input": "Invalid input\u{26A1}", "invalid.number": "Please submit a valid number (between 0 and 2.0)\u{26A1}", "invalid.token.login": "Le jeton a expiré. Connectez-vous à nouveau.", "invalid.token.login.admin": "Le jeton a expiré. Please log in again to view this page.\u{26A1}", @@ -812,18 +826,22 @@ const LocaleTranslationData = { "less.energy": "moins d'énergie", "line": "Ligne", "log.in": "Se Connecter", + "log.limit.required": "Number of logs to display must be within 1 to 1000\u{26A1}", + "log.message": "Log Message\u{26A1}", + "log.messages": "Log Messages\u{26A1}", "log.out": "Se Déconnecter", + "log.time": "Log Time\u{26A1}", + "log.type": "Log Type\u{26A1}", "login.failed": "Echec de la connexion", "login.success": "Login Successful\u{26A1}", "logo": "Logo", "manage": "Manage\u{26A1}", "map": "Carte", - "maps": "Plans", + "map.bad.digita": "Supérieur à 360, veuillez changer l'angle en un nombre compris entre 0 et 360", + "map.bad.digitb": "Moins de 0, veuillez changer l'angle en un nombre compris entre 0 et 360", "map.bad.load": "Fichier image de la carte requis", "map.bad.name": "Nom de la carte requis", "map.bad.number": "Pas un nombre, veuillez changer l'angle en un nombre entre 0 et 360", - "map.bad.digita": "Supérieur à 360, veuillez changer l'angle en un nombre compris entre 0 et 360", - "map.bad.digitb": "Moins de 0, veuillez changer l'angle en un nombre compris entre 0 et 360", "map.calibrate": "Étalonner", "map.calibration": "Statut d'étalonnage", "map.circle.size": "Taille du cercle de la carte", @@ -847,39 +865,35 @@ const LocaleTranslationData = { "map.notify.calibration.needed": "Étalonnage nécessaire pour l'affichage", "map.unavailable": "There's not an available map\u{26A1}", "map.upload.new.file": "Refaire", + "maps": "Plans", "max": "max\u{26A1}", "menu": "Menu", "meter": "Mèter", - "meters": "Mèters", "meter.create": "Create a Meter\u{26A1}", "meter.cumulative": "Cumulative:\u{26A1}", "meter.cumulativeReset": "Cumulative Reset:\u{26A1}", "meter.cumulativeResetEnd": "Cumulative Reset End:\u{26A1}", "meter.cumulativeResetStart": "Cumulative Reset Start:\u{26A1}", - "meter.edit.displayable.warning": "is not displayable but is used by the following displayable groups:\u{26A1}", + "meter.disableChecks": "Disable Checks\u{26A1}", "meter.edit.displayable.verify": "Given the group(s) listed above, do you want to cancel this change (click Cancel) or continue (click OK)?\u{26A1}", + "meter.edit.displayable.warning": "is not displayable but is used by the following displayable groups:\u{26A1}", "meter.enabled": "Mises à Jour du Mèters", "meter.endOnlyTime": "End Only Time:\u{26A1}", "meter.endTimeStamp": "End Time Stamp:\u{26A1}", - "meter.minVal": "Minimum Reading Value Check\u{26A1}", - "meter.maxVal": "Maximum Reading Value Check\u{26A1}", - "meter.minDate": "Minimum Reading Date Check\u{26A1}", - "meter.maxDate": "Maximum Reading Date Check\u{26A1}", - "meter.maxError": "Maximum Number of Errors Check\u{26A1}", - "meter.disableChecks": "Disable Checks\u{26A1}", "meter.failed.to.create.meter": "Failed to create a meter with message: \u{26A1}", "meter.failed.to.edit.meter": "Failed to edit meter with message: \u{26A1}", "meter.hidden": "At least one meter is not visible to you\u{26A1}", "meter.id": "Identifiant du Mèters", "meter.input.error": "Input invalid so meter not created or edited.\u{26A1}", - "meter.unit.change.requires": "needs to be changed before changing this unit's type\u{26A1}", - "meter.unitName": "Unit:\u{26A1}", - "meter.url": "URL", "meter.is.displayable": "Affichage Activées", "meter.is.enabled": "Mises à Jour Activées", "meter.is.not.displayable": "Affichage Désactivé", "meter.is.not.enabled": "Mises à Jour Désactivées", - "meter.unit.is.not.editable": "This meter's unit cannot be changed and was put back to the original value because: \u{26A1}", + "meter.maxDate": "Maximum Reading Date Check\u{26A1}", + "meter.maxError": "Maximum Number of Errors Check\u{26A1}", + "meter.maxVal": "Maximum Reading Value Check\u{26A1}", + "meter.minDate": "Minimum Reading Date Check\u{26A1}", + "meter.minVal": "Minimum Reading Value Check\u{26A1}", "meter.previousEnd": "Previous End Time Stamp:\u{26A1}", "meter.reading": "Reading:\u{26A1}", "meter.readingDuplication": "Reading Duplication:\u{26A1}", @@ -890,19 +904,26 @@ const LocaleTranslationData = { "meter.startTimeStamp": "Start Time Stamp:\u{26A1}", "meter.successfully.create.meter": "Successfully created a meter.\u{26A1}", "meter.successfully.edited.meter": "Successfully edited meter.\u{26A1}", - "meter.timeSort": "Time Sort:\u{26A1}", "meter.time.zone": "fuseau horaire du mètre", + "meter.timeSort": "Time Sort:\u{26A1}", "meter.type": "Type de Mèters", - "minute": "Minute\u{26A1}", + "meter.unit.change.requires": "needs to be changed before changing this unit's type\u{26A1}", + "meter.unit.is.not.editable": "This meter's unit cannot be changed and was put back to the original value because: \u{26A1}", + "meter.unitName": "Unit:\u{26A1}", + "meter.url": "URL", + "meters": "Mèters", "min": "min\u{26A1}", + "minute": "Minute\u{26A1}", "more.energy": "plus d'énergie", "more.options": "More Options\u{26A1}", "name": "Nom:", "navigation": "Navigation", "need.more.points": "Need more points\u{26A1}", "no": "no\u{26A1}", - "note": "Noter: ", "no.data.in.range": "No Data In Date Range\u{26A1}", + "no.logs": "No logs to display. Please select another log type or date range\u{26A1}", + "note": "Noter: ", + "num.logs.display": "Number of logs to display\u{26A1}", "oed": "Tableau de Bord Ouvert d'énergie", "oed.description": "Le Tableau de Bord Ouvert d'énergie est un projet open source indépendant. ", "oed.version": "OED version \u{26A1}", @@ -925,8 +946,8 @@ const LocaleTranslationData = { "rate.limit.error.first": "You have been rate limited by your OED site\u{26A1}", "rate.limit.error.second": "We suggest you try these in this order:\u{26A1}", "reading": "Reading:\u{26A1}", - "redo.cik.and.refresh.db.views": "Processing changes. This may take a while\u{26A1}", "readings.per.day": "Readings per Day\u{26A1}", + "redo.cik.and.refresh.db.views": "Processing changes. This may take a while\u{26A1}", "redraw": "Redessiner", "refresh.page.first": "Click the Refresh this page' button below to try again\u{26A1}", "refresh.page.second": "If you keep returning to this page wait longer and click 'Refresh this page' button\u{26A1}", @@ -940,23 +961,26 @@ const LocaleTranslationData = { "save.meter.edits": "Enregistrer les modifications de compteur", "save.role.changes": "Save role changes\u{26A1}", "second": "Second\u{26A1}", + "select.all": "Select All\u{26A1}", "select.groups": "Sélectionnez des Groupes", "select.map": "Select Map\u{26A1}", - "select.meter.type": "Select Meter Type\u{26A1}", "select.meter": "Sélectionnez de Mètres", "select.meter.group": "Select meter or group to graph\u{26A1}", + "select.meter.type": "Select Meter Type\u{26A1}", "select.meters": "Sélectionnez des Mètres", "select.unit": "Select Unit\u{26A1}", "show": "Montrer", + "show.all.logs": "Show All Logs\u{26A1} ", "show.grid": "Show grid\u{26A1}", "show.options": "Options de désancrage", + "show.in.pages": "Show in Pages\u{26A1}", "site.settings": "Site Settings\u{26A1}", "site.title": "Site Title\u{26A1}", "sort": "Sort Order\u{26A1}", "submit": "Soumettre", - "submitting": "submitting\u{26A1}", "submit.changes": "Soumettre les changements", "submit.new.user": "Submit new user\u{26A1}", + "submitting": "submitting\u{26A1}", "the.unit.of.meter": "The unit of meter\u{26A1}", "this.four.weeks": "Cette quatre semaines", "this.week": "Cette semaine", @@ -964,35 +988,26 @@ const LocaleTranslationData = { "threeD.date": "Date", "threeD.date.range.too.long": "Date Range Must be a year or less\u{26A1}", "threeD.incompatible": "Not Compatible with 3D\u{26A1}", - 'threeD.rendering': "Rendering\u{26A1}", "threeD.time": "Temps", - 'threeD.x.axis.label': 'Heures de la journée', - 'threeD.y.axis.label': 'Jours de l\'année calendaire', - "timezone.no": "Pas de fuseau horaire", "TimeSortTypes.decreasing": "décroissant", "TimeSortTypes.increasing": "en augmentant", + "timezone.no": "Pas de fuseau horaire", "today": "Aujourd'hui", "toggle.link": "Bascule du lien du diagramme", - "toggle.options" : "Basculer les options", + "toggle.options": "Basculer les options", "total": "total", "true": "Vrai", "TrueFalseType.false": "no\u{26A1}", "TrueFalseType.true": "yes\u{26A1}", "undefined": "undefined\u{26A1}", "unit": "Unit\u{26A1}", - "UnitRepresentType.quantity": "quantity\u{26A1}", - "UnitRepresentType.flow": "flow\u{26A1}", - "UnitRepresentType.raw": "raw\u{26A1}", - "UnitType.unit": "unit\u{26A1}", - "UnitType.meter": "meter\u{26A1}", - "UnitType.suffix": "suffix\u{26A1}", "unit.delete.failure": "Failed to deleted unit with error: \u{26A1}", "unit.delete.success": "Successfully deleted unit\u{26A1}", "unit.delete.unit": "Delete Unit\u{26A1}", "unit.destination.error": "as the destination unit\u{26A1}", - "unit.dropdown.displayable.option.none": "None\u{26A1}", - "unit.dropdown.displayable.option.all": "All\u{26A1}", "unit.dropdown.displayable.option.admin": "admin\u{26A1}", + "unit.dropdown.displayable.option.all": "All\u{26A1}", + "unit.dropdown.displayable.option.none": "None\u{26A1}", "unit.failed.to.create.unit": "Failed to create a unit.\u{26A1}", "unit.failed.to.delete.unit": "Delete cannot be done because this unit is used by the following\u{26A1}", "unit.failed.to.edit.unit": "Failed to edit unit.\u{26A1}", @@ -1008,8 +1023,14 @@ const LocaleTranslationData = { "unit.suffix": "Suffix:\u{26A1}", "unit.type.of.unit": "Type of Unit:\u{26A1}", "unit.type.of.unit.suffix": "Added suffix will set type of unit to suffix\u{26A1}", + "UnitRepresentType.flow": "flow\u{26A1}", + "UnitRepresentType.quantity": "quantity\u{26A1}", + "UnitRepresentType.raw": "raw\u{26A1}", "units": "Units\u{26A1}", "units.conversion.page.title": "Units and Conversions Visual Graphics\u{26A1}", + "UnitType.meter": "meter\u{26A1}", + "UnitType.suffix": "suffix\u{26A1}", + "UnitType.unit": "unit\u{26A1}", "unsaved.failure": "Changes failed to save\u{26A1}", "unsaved.success": "Changes saved\u{26A1}", "unsaved.warning": "You have unsaved change(s). Are you sure you want to leave?\u{26A1}", @@ -1041,20 +1062,23 @@ const LocaleTranslationData = { "uses": "uses\u{26A1}", "view.groups": "Visionner les groupes", "visit": " ou visitez notre ", - "visual.unit": "Units Visual Graphics\u{26A1}", - "visual.input.units.graphic": "Graphique Visuel des Unités D'Entrée", "visual.analyzed.units.graphic": "Graphique Visuel des Unités Analysées", + "visual.input.units.graphic": "Graphique Visuel des Unités D'Entrée", + "visual.unit": "Units Visual Graphics\u{26A1}", "website": "site web", "week": "Semaine", "yes": " yes\u{26A1}", "yesterday": "Hier", - "you.cannot.create.a.cyclic.group": "Vous ne pouvez pas créer un groupe cyclique" + "you.cannot.create.a.cyclic.group": "Vous ne pouvez pas créer un groupe cyclique", + 'threeD.rendering': "Rendering\u{26A1}", + 'threeD.x.axis.label': 'Heures de la journée', + 'threeD.y.axis.label': 'Jours de l\'année calendaire', }, "es": { "3D": "3D", + "4.weeks": "4 Semanas", "400": "400 Solicitud incorrecta", "404": "404 Página no encontrada", - "4.weeks": "4 Semanas", "action": "Acción", "add.new.meters": "Agregar nuevos medidores", "admin.only": "Solo administrador", @@ -1062,16 +1086,16 @@ const LocaleTranslationData = { "alphabetically": "Alfabéticamente", "area": "Área:", "area.but.no.unit": "Ha ingresado un área distinta a cero sin unidad de área.", + "area.calculate.auto": "Calcular el área del grupo", "area.error": "Por favor indique un número para el área", "area.normalize": "Normalizar según el área", - "area.calculate.auto": "Calcular el área del grupo", "area.unit": "Unidad de área:", "AreaUnitType.feet": "pies cuadrados", "AreaUnitType.meters": "metros cuadrados", "AreaUnitType.none": "sin unidad", - "ascending": "Ascendiente", - "as.meter.unit": "como unidad de medidor", "as.meter.defaultgraphicunit": "como unidad gráfica predeterminada del medidor", + "as.meter.unit": "como unidad de medidor", + "ascending": "Ascendiente", "bar": "Barra", "bar.interval": "Intervalo de barra", "bar.raw": "No se puede crear un gráfico de barras con unidades crudas como la temperatura", @@ -1097,8 +1121,6 @@ const LocaleTranslationData = { "confirm.action": "Confirmar acción", "contact.us": "Contáctenos", "conversion": "Conversión", - "conversions": "Conversiones", - "ConversionType.conversion": "conversión", "conversion.bidirectional": "Bidireccional:", "conversion.create.destination.meter": "La destinación no puede ser un medidor", "conversion.create.exists": "Esta conversión ya existe", @@ -1124,16 +1146,14 @@ const LocaleTranslationData = { "conversion.successfully.create.conversion": "Se creó una conversión con éxito.", "conversion.successfully.delete.conversion": "Se eliminó una conversión con éxito.", "conversion.successfully.edited.conversion": "Se editó una conversión con éxito", + "conversions": "Conversiones", + "ConversionType.conversion": "conversión", "create.conversion": "Crear una conversión", "create.group": "Crear un grupo", "create.map": "Crear un mapa", "create.unit": "Crear una unidad", "create.user": "Crear un usuario", "csv": "CSV", - "csvMeters": "CSV Meters\u{26A1}", - "csvReadings": "CSV Readings\u{26A1}", - "csv.file": "Archivo CSV:", - "csv.file.error": "El archivo debe estar en formato CSV o GZIP (.csv o .gz). ", "csv.clear.button": "Forma clara", "csv.common.param.gzip": "Gzip", "csv.common.param.header.row": "Fila de cabecera", @@ -1141,6 +1161,8 @@ const LocaleTranslationData = { "csv.download.size.limit": "Perdón, no tienes permiso para descargar por el número de puntos grande.", "csv.download.size.warning.size": "El tamaño todos los archivos juntos será de unos (usualmente dentro de 10% para exportaciones largas).", "csv.download.size.warning.verify": "Estás seguro que quieres descargar", + "csv.file": "Archivo CSV:", + "csv.file.error": "El archivo debe estar en formato CSV o GZIP (.csv o .gz). ", "csv.readings.param.create.meter": "Crear medidor", "csv.readings.param.honor.dst": "Seguir el horario de verano", "csv.readings.param.meter.identifier": "Identificador del medidor:", @@ -1155,6 +1177,8 @@ const LocaleTranslationData = { "csv.tab.readings": "Lecturas", "csv.upload.meters": "Subir medidores CSV", "csv.upload.readings": "Subir lecturas CSV", + "csvMeters": "CSV Meters\u{26A1}", + "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Valor personalizado", "date.range": 'Rango de fechas', "day": "Día", @@ -1164,53 +1188,53 @@ const LocaleTranslationData = { "default.area.normalize": "Normalizar lecturas según el área por defecto", "default.area.unit": "Unidad de área predeterminada", "default.bar.stacking": "Apilar barras por defecto", - "default.graph.type": "Tipo de gráfico por defecto", - "default.graph.settings": "Configuraciones predeterminadas del gráfico", - "defaultGraphicUnit": "Unidad del gráfico predeterminada:", - "default.language": "Idioma predeterminado", - "default.meter.reading.frequency": "Frecuencia de lectura del medidor predeterminada", - "default.site.title": "Título predeterminado de la página ", - "default.warning.file.size": "Advertencia predeterminada de tamaño del archivo", "default.file.size.limit": "Límite predeterminado de tamaño del archivo", + "default.graph.settings": "Configuraciones predeterminadas del gráfico", + "default.graph.type": "Tipo de gráfico por defecto", "default.help.url": "URL Documentación", - "default.time.zone": "Zona de horario predeterminada", - "default.meter.minimum.value": "Revisión del valor de lectura mínima del medidor predeterminado", + "default.language": "Idioma predeterminado", + "default.meter.disable.checks": "Desactivar revisiones de medidor predeterminado", + "default.meter.maximum.date": "Revisión de la fecha de lectura máxima del medidor predeterminado", + "default.meter.maximum.errors": "Número máximo de errores en la lectura del medidor", "default.meter.maximum.value": "Revisión del valor de lectura máxima del medidor predeterminado", "default.meter.minimum.date": "Revisión de la fecha de lectura mínima del medidor predeterminado", - "default.meter.maximum.date": "Revisión de la fecha de lectura máxima del medidor predeterminado", + "default.meter.minimum.value": "Revisión del valor de lectura mínima del medidor predeterminado", + "default.meter.reading.frequency": "Frecuencia de lectura del medidor predeterminada", "default.meter.reading.gap": "Deistancia predeterminada entre lecturas del medidor", - "default.meter.maximum.errors": "Número máximo de errores en la lectura del medidor", - "default.meter.disable.checks": "Desactivar revisiones de medidor predeterminado", + "default.site.title": "Título predeterminado de la página ", + "default.time.zone": "Zona de horario predeterminada", + "default.warning.file.size": "Advertencia predeterminada de tamaño del archivo", + "defaultGraphicUnit": "Unidad del gráfico predeterminada:", "delete.group": "Borrar grupo", "delete.map": "Borrar mapa", "delete.self": "No puedes eliminar tu propia cuenta.", "delete.user": "Borrar usario", "descending": "Descendente", - "discard.changes": "Descartar los cambios", "disable": "Desactivar", + "discard.changes": "Descartar los cambios", "displayable": "Visualizable:", - "DisplayableType.none": "ninguno", - "DisplayableType.all": "todo", "DisplayableType.admin": "administrador", - "error.bounds": "Debe ser entre {min} y {max}.", - "error.displayable": "El elemento visual determinado como falso porque no hay unidad seleccionada.", - "error.displayable.meter": "Las unidades de medición determinarán al elemento visual como ninguno.", - "error.displayable.suffix.input": "Suffix input will set displayable to none.\u{26A1}", - "error.greater": "Debe ser más que {min}.", - "error.gps": "Latitud deber ser entre -90 y 90, y longitud de entre -180 y 180.", - "error.negative": "No puede ser negativo.", - "error.required": "Campo requerido", - "error.unknown": "¡Ups! Ha ocurrido un error.", + "DisplayableType.all": "todo", + "DisplayableType.none": "ninguno", "edit": "Editar", - "edited": "editado", "edit.a.group": "Editar un grupo", "edit.a.meter": "Editar un medidor", "edit.group": "Editar grupo", "edit.meter": "Details/Editar medidor\u{26A1}", "edit.unit": "Editar unidad", "edit.user": "Editar Usuario", + "edited": "editado", "enable": "Activar", "error.bar": "Mostrar las barras de errores", + "error.bounds": "Debe ser entre {min} y {max}.", + "error.displayable": "El elemento visual determinado como falso porque no hay unidad seleccionada.", + "error.displayable.meter": "Las unidades de medición determinarán al elemento visual como ninguno.", + "error.displayable.suffix.input": "Suffix input will set displayable to none.\u{26A1}", + "error.gps": "Latitud deber ser entre -90 y 90, y longitud de entre -180 y 180.", + "error.greater": "Debe ser más que {min}.", + "error.negative": "No puede ser negativo.", + "error.required": "Campo requerido", + "error.unknown": "¡Ups! Ha ocurrido un error.", "export.graph.data": "Exportar los datos del gráfico", "export.raw.graph.data": "Exportar los datos del medidor del gráfico", "failed.to.create.map": "No se pudo crear el mapa", @@ -1220,6 +1244,7 @@ const LocaleTranslationData = { "failed.to.link.graph": "No se pudo vincular el gráfico", "failed.to.submit.changes": "No se pudo entregar los cambios", "false": "Falso", + "from.1.to.1000": "from 1 to 1000\u{26A1}", "gps": "GPS: latitud, longitud", "graph": "Gráfico", "graph.settings": "Graph Settings\u{26A1}", @@ -1227,12 +1252,12 @@ const LocaleTranslationData = { "group": "Grupo", "group.all.meters": "Todos los medidores", "group.area.calculate": "Calcular el área del grupo", - "group.area.calculate.header": "Área del grupo se establecerá en ", + "group.area.calculate.error.group.unit": "No hay unidad de área del grupo", "group.area.calculate.error.header": "Los siguientes medidores fueron excluidos de la suma porque:", - "group.area.calculate.error.zero": ": el área no está determinada o es cero", - "group.area.calculate.error.unit": ": el área es distinta a cero pero no tiene unidad", "group.area.calculate.error.no.meters": "No hay medidores en el grupo", - "group.area.calculate.error.group.unit": "No hay unidad de área del grupo", + "group.area.calculate.error.unit": ": el área es distinta a cero pero no tiene unidad", + "group.area.calculate.error.zero": ": el área no está determinada o es cero", + "group.area.calculate.header": "Área del grupo se establecerá en ", "group.create.nounit": "La unidad predeterminada del gráfico fue cambiado a sin unidad de ", "group.delete.group": "Borrar grupo", "group.delete.issue": "está en los siguientes grupos y no se puede borrar", @@ -1250,14 +1275,14 @@ const LocaleTranslationData = { "group.hidden": "Hay por lo menos un grupo que no es visible para tí", "group.input.error": "Entrada no válida, por tanto no se creó o editó el grupo", "group.name.error": "Por favor indique un nombre válido: (debe tener por lo menos un carácter que no sea un espacio blanco)", - "groups": "Grupos", "group.successfully.create.group": "Grupo creado con éxito.", "group.successfully.edited.group": "Grupo editado con éxito.", + "groups": "Grupos", "groups.select": "Seleccionar grupos", "has.no.data": "No hay datos actuales", "has.used": "ha utilizado", - "header.pages": "Páginas", "header.options": "Opciones", + "header.pages": "Páginas", "help": "Ayuda", "help.admin.conversioncreate": "Esta página permite a los administradores crear conversiones. Por favor visite {link} para más detalles e información.", "help.admin.conversionedit": "Esta página permite a los administradores editar conversiones. Por favor visite {link} para más detalles e información", @@ -1277,14 +1302,13 @@ const LocaleTranslationData = { "help.admin.users": "Esta página permite a los administradores ver y editar usarios. Por favor, visite {link} para más detalles e información.", "help.csv.meters": "Esta página permite a los administradores cargar medidores a través de un archivo CSV. Visite {enlace} para obtener más detalles e información.", "help.csv.readings": "Esta página permite a ciertos usuarios cargar lecturas a través de un archivo CSV. Visite {link} para obtener más detalles e información.", + "help.groups.area.calculate": "Esto sumará el área de todos los medidores en este grupo que tengan un área distinta a cero y una unidad de área. Ignorará cualquier medidor que no tiene área o unidad de área. Si este grupo no tiene unidad de área, no hará nada.", "help.groups.groupdetails": "Esta página muestra información detallada de un grupo. Por favor visite {link} para obtener más detalles e información.", "help.groups.groupview": "Esta página muestra información sobre grupos. Por favor, visite {link} para más detalles e información.", - "help.groups.area.calculate": "Esto sumará el área de todos los medidores en este grupo que tengan un área distinta a cero y una unidad de área. Ignorará cualquier medidor que no tiene área o unidad de área. Si este grupo no tiene unidad de área, no hará nada.", "help.home.area.normalize": "Alterna la normalización por área. Medidores/Grupos sin área quedarán escondidos. Por favor visite {link} para obtener más detalles e información.", "help.home.bar.days.tip": "Permite al usuario seleccionar el número de días deseado para cada barra. Por favor, visite {link} para más detalles e información.", "help.home.bar.interval.tip": "Selecciona el intervalo de tiempo (día, semana o 4 semanas) para cada barra. Por favor, visite {link} para más detalles e información.", "help.home.bar.stacking.tip": "Apila las barras una encima de la otra. Por favor, visite {link} para obtener más detalles e información.", - "help.home.map.interval.tip": "Seleciona el intervalo de tiempo (el último día, semana o 4 semanas) para el mapa correspondiente al intervalo de tiempo de la barra. Por favor visite {link} para mas detalles e información", "help.home.chart.plotly.controls": "Estos controles son proporcionados por Plotly, el paquete de gráficos utilizado por OED. Por lo general no se necesitan pero se proporcionan por si se desea ese nivel de control. Tenga en cuenta que es posible que algunas de estas opciones no interactúen bien con las funciones de OED. Consulte la documentación de Plotly en {link}.", "help.home.chart.redraw.restore": "OED automáticamente toma el promedio de los datos cuando es necesario para que los gráficos tengan un número razonable de puntos. Si usa los controles debajo del gráfico para desplazarse y / o acercarse, puede encontrar que la resolución en este nivel de promedio no es la que desea. Al hacer clic en el botón \"Redraw\" OED volverá a calcular el promedio y obtendrá una resolución más alta para el número de puntos que muestra. Si desea restaurar el gráfico al rango completo de fechas, haga clic en el botón \"Restore\" button. Por favor visite {link} para obtener más detalles e información.", "help.home.chart.select": "Se puede usar cualquier tipo de gráfico con cualquier combinación de grupos y medidores. Los gráficos de líneas muestran el uso (por ejemplo, kW) con el tiempo. Puede hacer zoom y desplazarse con los controles justo debajo del gráfico. La barra muestra el uso total (por ejemplo, kWh) para el período de tiempo de cada barra donde se puede controlar el período de tiempo. Comparar le permite ver el uso actual comparado con el uso del período anterior durante un día, una semana y cuatro semanas. Los gráficos del mapa muestran una imagen espacial de cada medidor donde el tamaño del círculo está relacionado con cuatro semanas de uso. Las gráficas 3D muestran el uso por día y el uso por hora del día. Hacer clic en uno de estas opciones las registra en ese gráfico. Por favor visite {link} para obtener más detalles e información.", @@ -1293,6 +1317,7 @@ const LocaleTranslationData = { "help.home.error.bar": "Alternar barras de error con el valor mínimo y máximo. Por favor, vea {link} para más detalles e información.", "help.home.export.graph.data": "Con el botón \"Exportar datos del gráfico\", uno puede exportar los datos del gráfico al ver una línea o barra el gráfico. La función de zoom y desplazamiento en el gráfico de líneas le permite controlar el período de tiempo de los datos exportados. El botón \"Exportar data de gráfico\" da los puntos de datos para el gráfico y no los datos originales del medidor. \"Exportar el dato gráfhico de medidor\" proporciona los datos subyacentes del medidor (solo gráficos de líneas). Por favor visite {link} para obtener más detalles e información.", "help.home.history": "Permite al usuario navegar por el historial reciente de gráficos. Por favor visite {link} para obtener más detalles e información.", + "help.home.map.interval.tip": "Seleciona el intervalo de tiempo (el último día, semana o 4 semanas) para el mapa correspondiente al intervalo de tiempo de la barra. Por favor visite {link} para mas detalles e información", "help.home.navigation": "El botón \"Gráfico\" va a la página de gráficos, el desplegable \"Páginas\" permite la navigación a las páginas de información, el desplegable \"Opciones\" permite la selección del idioma, opciones de ocultar y de iniciar/cerrar sesión y el botón \"Ayuda\" va a las páginas de ayuda. Busca ayuda en los menús desplegables o las páginas vínculadas para más información.", "help.home.readings.per.day": "El número de lecturas mostrado para cada día en un gráfico 3D. Por favor visite {link} para obtener más detalles e información.", "help.home.select.dateRange": "Seleccione el rango de datos usado en el gráfico mostrado. Para gráficos 3D debe ser un año o menos. Por favor visite {link} para obtener más detalles e información.", @@ -1321,6 +1346,7 @@ const LocaleTranslationData = { "input.gps.coords.second": "de esta forma -> latitud, longitud", "input.gps.range": "Coordenada GPS no válida, la latitud debe ser un número entero entre -90 y 90, la longitud debe ser un número entero entre -180 y 180. Usted puso: ", "insufficient.readings": "Hay insuficientes datos de lecturas para procesar la comparación de ", + "invalid.input": "Invalid input\u{26A1}", "invalid.number": "Por favor indique un número válido (entre 0 a 2.0)", "invalid.token.login": "El token se ha vencido. Inicie la sesión nuevamente", "invalid.token.login.admin": "El token se ha vencido. Inicie la sesión nuevamente para ver esta página.", @@ -1335,18 +1361,22 @@ const LocaleTranslationData = { "less.energy": "menos energía", "line": "Línea", "log.in": "Iniciar sesión", + "log.limit.required": "Number of logs to display must be within 1 to 1000\u{26A1}", + "log.message": "Log Message\u{26A1}", + "log.messages": "Log Messages\u{26A1}", "log.out": "Cerrar sesión", + "log.time": "Log Time\u{26A1}", + "log.type": "Log Type\u{26A1}", "login.failed": "Error al iniciar sesión", "login.success": "Éxito al iniciar sesión", "logo": "Logo", "manage": "Gestionar", "map": "Mapa", - "maps": "Mapas", + "map.bad.digita": "Mayor a 360, por favor cambiar el angúlo a un número entre 0 a 360", + "map.bad.digitb": "Menor a 0, por favor cambiar el angúlo a un número entre 0 a 360", "map.bad.load": "Se necesita el archivo de la imagen del mapa", "map.bad.name": "Se necesita un nombre para el mapa", "map.bad.number": "No es número, por favor cambiar el angúlo a un número entre 0 a 360", - "map.bad.digita": "Mayor a 360, por favor cambiar el angúlo a un número entre 0 a 360", - "map.bad.digitb": "Menor a 0, por favor cambiar el angúlo a un número entre 0 a 360", "map.calibrate": "Calibrar", "map.calibration": "Estado de calibración", "map.circle.size": "Tamaño del círculo en el mapa", @@ -1370,39 +1400,35 @@ const LocaleTranslationData = { "map.notify.calibration.needed": "Necesita calibración antes de visualizar", "map.unavailable": "There's not an available map\u{26A1}", "map.upload.new.file": "Rehacer", + "maps": "Mapas", "max": "máximo", "menu": "Menú", "meter": "Medidor", - "meters": "Medidores", "meter.create": "Crear un medidor", "meter.cumulative": "Cumulativo", "meter.cumulativeReset": "Reinicio cumulativo:", "meter.cumulativeResetEnd": "Final del reinicio cumulativo:", "meter.cumulativeResetStart": "Comienzo del reinicio cumulativo:", - "meter.edit.displayable.warning": "is not displayable but is used by the following displayable groups:\u{26A1}", + "meter.disableChecks": "Desactivar revisiones", "meter.edit.displayable.verify": "Given the group(s) listed above, do you want to cancel this change (click Cancel) or continue (click OK)?\u{26A1}", + "meter.edit.displayable.warning": "is not displayable but is used by the following displayable groups:\u{26A1}", "meter.enabled": "Medidor activado", "meter.endOnlyTime": "Solo tiempos finales.", "meter.endTimeStamp": "Marca de tiempo al final:", - "meter.minVal": "Revisión del valor mínimo de lectura", - "meter.maxVal": "Revisión del valor máximo de lectura", - "meter.minDate": "Revisión de la fecha mínima de lectura", - "meter.maxDate": "Revisión de la fecha máxima de lectura", - "meter.maxError": "Revisión del número máximo de errores", - "meter.disableChecks": "Desactivar revisiones", "meter.failed.to.create.meter": "No se pudo crear un medidor con mensaje: ", "meter.failed.to.edit.meter": "No se pudo editar un medidor con mensaje: ", "meter.hidden": "Al menos un medidor no es visible para tí.", "meter.id": "ID del medidor", "meter.input.error": "Entrada no válida, por tanto no se creó o editó el medidor.", - "meter.unit.change.requires": "necesita cambiarse antes de cambiar el tipo de esta unidad", - "meter.unitName": "Unidad:", - "meter.url": "URL:", "meter.is.displayable": "El medidor es visualizable", "meter.is.enabled": "Actualizaciones activadas", "meter.is.not.displayable": "El medidor no es visualizable", "meter.is.not.enabled": "El medidor no está activado", - "meter.unit.is.not.editable": "La unidad de este medidor no puede cambiarse y se mantuvo el valor original porque: ", + "meter.maxDate": "Revisión de la fecha máxima de lectura", + "meter.maxError": "Revisión del número máximo de errores", + "meter.maxVal": "Revisión del valor máximo de lectura", + "meter.minDate": "Revisión de la fecha mínima de lectura", + "meter.minVal": "Revisión del valor mínimo de lectura", "meter.previousEnd": "Marca de tiempo del final anterior:", "meter.reading": "Lectura:", "meter.readingDuplication": "Duplicación de lectura:", @@ -1413,19 +1439,26 @@ const LocaleTranslationData = { "meter.startTimeStamp": "Marca de tiempo al inicio:", "meter.successfully.create.meter": "Éxito al crear el medidor.", "meter.successfully.edited.meter": "Éxito al editar el medidor.", - "meter.timeSort": "Ordenar por tiempo:", "meter.time.zone": "Zona horaria:", + "meter.timeSort": "Ordenar por tiempo:", "meter.type": "Tipo:", - "minute": "Minuto", + "meter.unit.change.requires": "necesita cambiarse antes de cambiar el tipo de esta unidad", + "meter.unit.is.not.editable": "La unidad de este medidor no puede cambiarse y se mantuvo el valor original porque: ", + "meter.unitName": "Unidad:", + "meter.url": "URL:", + "meters": "Medidores", "min": "mínimo", + "minute": "Minuto", "more.energy": "más energía", "more.options": "More Options\u{26A1}", "name": "Nombre:", "navigation": "Navegación", "need.more.points": "Nececita más puntos", "no": "no", - "note": "Nota: ", "no.data.in.range": "No hay datos para el rango de fechas", + "no.logs": "No logs to display. Please select another log type or data range\u{26A1}", + "note": "Nota: ", + "num.logs.display": "Number of logs to display\u{26A1}", "oed": "Panel de Energía Abierto", "oed.description": "Open Energy Dashboard es un proyecto independiente. ", "oed.version": "Versión OED", @@ -1448,8 +1481,8 @@ const LocaleTranslationData = { "rate.limit.error.first": "You have been rate limited by your OED site\u{26A1}", "rate.limit.error.second": "We suggest you try these in this order:\u{26A1}", "reading": "Lectura:", - "redo.cik.and.refresh.db.views": "Procesando los cambios. Esto tardará un momento.", "readings.per.day": "Lecturas por día", + "redo.cik.and.refresh.db.views": "Procesando los cambios. Esto tardará un momento.", "redraw": "Redibujar", "refresh.page.first": "Click the Refresh this page' button below to try again\u{26A1}", "refresh.page.second": "If you keep returning to this page wait longer and click 'Refresh this page' button\u{26A1}", @@ -1463,26 +1496,28 @@ const LocaleTranslationData = { "save.meter.edits": "Guardar las ediciones al medidor", "save.role.changes": "Guardar los cambios de rol", "second": "Segundo", + "select.all": "Select All\u{26A1}", "select.groups": "Seleccionar grupos", "select.map": "Seleccionar mapa", - "select.meter.type": "Seleccionar tipo de medidor", "select.meter": "Seleccionar medidor", "select.meter.group": "Seleccionar medidor o grupo para hacer gráfico", + "select.meter.type": "Seleccionar tipo de medidor", "select.meters": "Seleccionar medidores", "select.unit": "Seleccionar unidad", "show": "Mostrar", + "show.all.logs": "Show All Logs\u{26A1} ", "show.grid": "Mostrar rejilla", "show.options": "Mostrar opciones", + "show.in.pages": "Show in Pages\u{26A1}", "site.settings": "Site Settings\u{26A1}", "site.title": "Site Title\u{26A1}", "sort": "Sort Order\u{26A1}", "submit": "Enviar", - "submitting": "Enviando", "submit.changes": "Ingresar los cambios", "submit.new.user": "Ingresar un nuevo usario", + "submitting": "Enviando", "the.unit.of.meter": "La unidad del medidor", "this.four.weeks": "Estas cuatro semanas", - "timezone.no": "sin zona horaria", "this.week": "Esta semana", "threeD.area.incompatible": "
es incompatible
con normalización del área", "threeD.date": "Fecha", @@ -1494,28 +1529,23 @@ const LocaleTranslationData = { "threeD.y.axis.label": "Días del año calendario", "TimeSortTypes.decreasing": "decreciente", "TimeSortTypes.increasing": "creciente", + "timezone.no": "sin zona horaria", "today": "Hoy", "toggle.link": "Alternar enlace de gráfico", - "toggle.options" : "Alternar opciones", + "toggle.options": "Alternar opciones", "total": "total", "true": "Verdad", "TrueFalseType.false": "no", "TrueFalseType.true": "sí", "undefined": "indefinido", "unit": "Unidad", - "UnitRepresentType.quantity": "cantidad", - "UnitRepresentType.flow": "flujo", - "UnitRepresentType.raw": "crudo", - "UnitType.unit": "unidad", - "UnitType.meter": "medidor", - "UnitType.suffix": "sufijo", "unit.delete.failure": "No se pudo borrar la unidad con error: ", "unit.delete.success": "Éxito al borrar la unidad", "unit.delete.unit": "Borrar la unidad", "unit.destination.error": "como la unidad de la destinación", - "unit.dropdown.displayable.option.none": "Ninguna", - "unit.dropdown.displayable.option.all": "Todo", "unit.dropdown.displayable.option.admin": "administrador", + "unit.dropdown.displayable.option.all": "Todo", + "unit.dropdown.displayable.option.none": "Ninguna", "unit.failed.to.create.unit": "No se pudo crear la unidad", "unit.failed.to.delete.unit": "No se puede borrar por que esta unidad está siendo usada por lo siguiente", "unit.failed.to.edit.unit": "No se pudo editar la unidad", @@ -1531,8 +1561,14 @@ const LocaleTranslationData = { "unit.suffix": "Sufijo:", "unit.type.of.unit": "Tipo de unidad:", "unit.type.of.unit.suffix": "El sufijo agregado determina que el tipo de la unidad es sufijo", + "UnitRepresentType.flow": "flujo", + "UnitRepresentType.quantity": "cantidad", + "UnitRepresentType.raw": "crudo", "units": "Unidades", "units.conversion.page.title": "Gráficos Visuales de Unidades y Conversiones", + "UnitType.meter": "medidor", + "UnitType.suffix": "sufijo", + "UnitType.unit": "unidad", "unsaved.failure": "No se pudieron guardar los cambios", "unsaved.success": "Se guardaron los cambios", "unsaved.warning": "Tienes cambios sin guardar. ¿Estás seguro que quieres salir?", @@ -1564,14 +1600,14 @@ const LocaleTranslationData = { "uses": "uses\u{26A1}", "view.groups": "Ver grupos", "visit": " o visite nuestro ", - "visual.unit": "Units Visual Graphics\u{26A1}", - "visual.input.units.graphic": "Gráfico Visual de Unidades de Entrada", "visual.analyzed.units.graphic": "Gráfico Visual de Unidades Analizadas", + "visual.input.units.graphic": "Gráfico Visual de Unidades de Entrada", + "visual.unit": "Units Visual Graphics\u{26A1}", "website": "sitio web", "week": "semana", "yes": "sí", "yesterday": "Ayer", - "you.cannot.create.a.cyclic.group": "No se puede crear un grupo cíclico" + "you.cannot.create.a.cyclic.group": "No se puede crear un grupo cíclico", } } diff --git a/src/client/app/utils/api/LogsApi.ts b/src/client/app/utils/api/LogsApi.ts index d1f2a4211..11d58e8db 100644 --- a/src/client/app/utils/api/LogsApi.ts +++ b/src/client/app/utils/api/LogsApi.ts @@ -5,7 +5,8 @@ */ import ApiBackend from './ApiBackend'; -import {LogData} from '../../types/redux/logs'; +import { LogData } from '../../types/redux/logs'; +import { TimeInterval } from '../../../../common/TimeInterval'; export default class LogsApi { private readonly backend: ApiBackend; @@ -25,4 +26,11 @@ export default class LogsApi { public async error(log: LogData): Promise { return await this.backend.doPostRequest('/api/logs/error', log); } + + public async getLogsByDateRangeAndType(timeInterval: TimeInterval, logTypes: string, logLimit: string): Promise { + const request = await this.backend.doGetRequest('/api/logs/logsmsg/getLogsByDateRangeAndType', + { timeInterval: timeInterval.toString(), logTypes: logTypes, logLimit: logLimit }); + return request as LogData[]; + } + } diff --git a/src/scripts/backupScript.sh b/src/scripts/backupScript.sh new file mode 100755 index 000000000..091dd4657 --- /dev/null +++ b/src/scripts/backupScript.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# The OED Docker database container must be running for this script to work + +# Input the pathname for the desired backup directory +# ie PATH="/home//path_to/database_dumps/" +# This path MUST exist, otherwise, this script will attempt to create the directory, or fail. + +# This could probably be programmatically populated. Currently needs to be set manually +db_dump_path="/home//database_dumps" #INPUT REQUIRED + +# Checks to see if the directory is exists +# If not, it will display a message, and attempt to create the backup directory +if [ ! -d "$db_dump_path" ]; then + echo "Backup directory does not exist. Creating it now..." + mkdir -p "$db_dump_path" || { echo "Failed to create directory. Exiting."; exit 1; } +fi + +# Generate a timestamp to append to the dump file. +date=`date +%Y-%m-%d_%H_%M_%S` + +# Set the final path for the backup file +final_path="${db_dump_path}/dump_${date}.sql" + +# Perform the backup using pg_dump +docker compose exec database pg_dump -U oed > "$final_path" + +echo "OED database backup placed in ${final_path}" diff --git a/src/scripts/checkWebsiteStatusCron.bash b/src/scripts/checkWebsiteStatusCron.bash new file mode 100644 index 000000000..b4e1cbb55 --- /dev/null +++ b/src/scripts/checkWebsiteStatusCron.bash @@ -0,0 +1,23 @@ +#!/bin/bash +# * +# * This Source Code Form is subject to the terms of the Mozilla Public +# * License, v. 2.0. If a copy of the MPL was not distributed with this +# * file, You can obtain one at http://mozilla.org/MPL/2.0/. +# * + +# This check the website status at hour level. +# This should be copied to /etc/ or /etc/cron.hourly/ or /etc/cron.daily and the copy renamed so that its function will be clear to admins. + +# Check if a URL is provided as an argument +if [ -z "$1" ]; then + echo "Error: No URL provided. Usage: $0 URL-to-check" + exit 1 +fi + +URL=$1 + +# The absolute path the project root directory (OED) +cd '/example/path/to/project/OED' + +# The following line should NOT need to be edited except by devs or if you have an old system with only docker-compose. +docker compose run --rm web npm run --silent checkWebsiteStatus -- $URL &>> /dev/null & diff --git a/src/server/log.js b/src/server/log.js index 2c1a24196..838e32f4d 100644 --- a/src/server/log.js +++ b/src/server/log.js @@ -5,6 +5,7 @@ const fs = require('fs'); const logFile = require('./config').logFile; const LogEmail = require('./models/LogEmail'); +const LogMsg = require('./models/LogMsg'); const { getConnection } = require('./db'); const moment = require('moment'); @@ -50,7 +51,8 @@ class Logger { * @param {boolean?} skipMail Don't e-mail this message even if we would normally emit an e-mail for this level. */ log(level, message, error = null, skipMail = false) { - let messageToLog = `[${level.name}@${moment().format('YYYY-MM-DDTHH:mm:ss.SSSZ')}] ${message}\n`; + let logTime = moment(); + let messageToLog = `[${level.name}@${logTime.format('YYYY-MM-DDTHH:mm:ss.SSSZ')}] ${message}\n`; const conn = getConnection(); @@ -71,11 +73,21 @@ class Logger { // Always log to the logfile. if (this.logToFile) { - fs.appendFile(logFile, messageToLog, err => { + fs.appendFile(logFile, messageToLog, async err => { if (err) { console.error(`Failed to write to log file: ${err} (${err.stack})`); // tslint:disable-line no-console } }); + + // Write the new log to the database + const logMsg = new LogMsg(level.name, message, logTime); + (async () => { + try { + await logMsg.insert(conn); + } catch (err) { + console.error(`Failed to write log to database: ${err} (${err.stack})`); + } + })(); } // Only log elsewhere if given a high enough priority level. diff --git a/src/server/migrations/1.0.0-2.0.0/sql/logmsg/create_log_types_enum.sql b/src/server/migrations/1.0.0-2.0.0/sql/logmsg/create_log_types_enum.sql new file mode 100644 index 000000000..64df5b2ab --- /dev/null +++ b/src/server/migrations/1.0.0-2.0.0/sql/logmsg/create_log_types_enum.sql @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +DO $$ BEGIN + CREATE TYPE log_msg_type AS ENUM('INFO', 'WARN', 'ERROR', 'DEBUG', 'SILENT'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; \ No newline at end of file diff --git a/src/server/migrations/1.0.0-2.0.0/sql/logmsg/create_logmsg_table.sql b/src/server/migrations/1.0.0-2.0.0/sql/logmsg/create_logmsg_table.sql new file mode 100644 index 000000000..1e6cfc5d8 --- /dev/null +++ b/src/server/migrations/1.0.0-2.0.0/sql/logmsg/create_logmsg_table.sql @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +--create logmsg table +CREATE TABLE IF NOT EXISTS logmsg ( + id SERIAL PRIMARY KEY, + log_type log_msg_type NOT NULL, + log_message TEXT NOT NULL, + log_time TIMESTAMP NOT NULL +); + +-- TODO Consider index optimization for queries \ No newline at end of file diff --git a/src/server/models/LogMsg.js b/src/server/models/LogMsg.js new file mode 100644 index 000000000..119f0e345 --- /dev/null +++ b/src/server/models/LogMsg.js @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const database = require('./database'); +const sqlFile = database.sqlFile; + +class LogMsg { + /** + * Creates a new log + * @param logType log type. Have to be INFO, WARN, ERROR, or SILENT + * @param logMessage log information + * @param {Moment} logTime the date and time of the log + */ + constructor(logType, logMessage, logTime) { + this.logType = logType; + this.logMessage = logMessage; + this.logTime = logTime; + } + + /** + * Creates a new log from data in the row + * @param {*} row The row from which the log is to be created. + * @returns The new log object. + */ + static mapRow(row) { + return new LogMsg(row.log_type, row.log_message, row.log_time); + } + + /** + * Returns a promise to create the logging table + * @param conn the database connection to use + * @returns {Promise.<>} + */ + static createTable(conn) { + return conn.none(sqlFile('logmsg/create_logmsg_table.sql')); + } + + /** + * Returns a promise to create the logMsgType enum. + * @param {*} conn The connection to use. + * @returns {Promise.<>} + */ + static createLogMsgTypeEnum(conn) { + return conn.none(sqlFile('logmsg/create_log_types_enum.sql')); + } + + /** + * Returns a promise to insert this log into the database + * @param conn the database connection to use + * @returns {Promise.<>} + */ + async insert(conn) { + const logMsg = this; + await conn.none(sqlFile('logmsg/insert_new_logmsg.sql'), { + logType: logMsg.logType, + logMessage: logMsg.logMessage, + logTime: logMsg.logTime.format('YYYY-MM-DDTHH:mm:ss.SSS') + }); + } + + /** + * Returns a promise to get all of the logs in between two dates. + * @param {Date} startDate start date of the range to get logs + * @param {Date} endDate end date of the range to get logs + * @param {Array} logTypes array of log types to get logs + * @param {Number} logLimit the maximum number of logs to return + * @param conn is the connection to use. + * @returns {Promise.>} + */ + static async getLogsByDateRangeAndType(startDate = null, endDate = null, logTypes, logLimit = 100, conn) { + const rows = await conn.any(sqlFile('logmsg/get_logmsgs_from_dates_and_type.sql'), { + startDate: startDate || '-infinity', + endDate: endDate || 'infinity', + logTypes: logTypes, + logLimit: logLimit + }); + + return rows.map(LogMsg.mapRow); + } +} +module.exports = LogMsg; \ No newline at end of file diff --git a/src/server/models/database.js b/src/server/models/database.js index 50ef1de89..8bb3f3ebc 100644 --- a/src/server/models/database.js +++ b/src/server/models/database.js @@ -79,6 +79,7 @@ async function createSchema(conn) { const Configfile = require('./obvius/Configfile'); const Migration = require('./Migration'); const LogEmail = require('./LogEmail'); + const LogMsg = require('./LogMsg'); const Baseline = require('./Baseline'); const { Map } = require('./Map'); const Unit = require('./Unit'); @@ -106,10 +107,12 @@ async function createSchema(conn) { await Group.createTables(conn); await Migration.createTable(conn); await LogEmail.createTable(conn); + await LogMsg.createLogMsgTypeEnum(conn); + await LogMsg.createTable(conn); await Reading.createReadingsMaterializedViews(conn); await Reading.createCompareReadingsFunction(conn); // For 3D reading - await Reading.create3DReadingsFunction(conn); + await Reading.create3DReadingsFunction(conn); await Baseline.createTable(conn); await Map.createTable(conn); await conn.none(sqlFile('baseline/create_function_get_average_reading.sql')); diff --git a/src/server/routes/logs.js b/src/server/routes/logs.js index 7e74ad863..1111d318f 100644 --- a/src/server/routes/logs.js +++ b/src/server/routes/logs.js @@ -8,6 +8,9 @@ const express = require('express'); const { log } = require('../log'); const validate = require('jsonschema').validate; const adminAuthenticator = require('./authenticator').adminAuthMiddleware; +const LogMsg = require('../models/LogMsg'); +const { getConnection } = require('../db'); +const { TimeInterval } = require('../../common/TimeInterval'); const router = express.Router(); router.use(adminAuthenticator('log API')); @@ -22,6 +25,28 @@ const validLog = { } } } + +const validLogMsg = { + type: 'object', + required: ['timeInterval', 'logTypes', 'logLimit'], + maxProperties: 3, + properties: { + timeInterval: { + // it should check for format: 'date-time' but this won't work for case where time is not provided + // when time is not provided, timeInterval value will be 'all' so just check type is string for now + type: 'string', + }, + logTypes: { + type: 'string', + pattern: '^(INFO|WARN|ERROR|SILENT|DEBUG)(,(INFO|WARN|ERROR|SILENT|DEBUG))*$' + }, + logLimit: { + type: 'string', + // as logLimit is being sent as string, using pattern to validate it represents a number from 1 to 1000 + pattern: '^(?:[1-9][0-9]{0,2}|1000)$' + }, + } +} router.post('/info', async (req, res) => { const validationResult = validate(req.body, validLog); if (validationResult.valid) { @@ -55,4 +80,25 @@ router.post('/error', async (req, res) => { } }); +router.get('/logsmsg/getLogsByDateRangeAndType', async (req, res) => { + const validationResult = validate(req.query, validLogMsg); + if (!validationResult.valid) { + log.error('invalid request to getLogsByDateRangeAndType'); + res.sendStatus(400); + } else { + const conn = getConnection(); + try { + const logLimit = parseInt(req.query.logLimit); + const timeInterval = TimeInterval.fromString(req.query.timeInterval); + const logTypes = req.query.logTypes.split(','); + const rows = await LogMsg.getLogsByDateRangeAndType( + timeInterval.startTimestamp, timeInterval.endTimestamp, logTypes, logLimit, conn); + res.json(rows); + } catch (err) { + log.error(`Failed to fetch logs filter by date range and type: ${err}`); + res.sendStatus(500); + } + } +}); + module.exports = router; diff --git a/src/server/routes/obvius.js b/src/server/routes/obvius.js index 19bad6443..d2a7ad785 100644 --- a/src/server/routes/obvius.js +++ b/src/server/routes/obvius.js @@ -100,7 +100,7 @@ function handleStatus(req, res) { /** * Logs the Obvius request and sets the req.IP field to be the ip address. */ -function obviusLog(req, res, next){ +function obviusLog(req, res, next) { // Log the IP of the requester const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; req.IP = ip; @@ -111,7 +111,7 @@ function obviusLog(req, res, next){ /** * Verifies an Obvius request via username and password. */ -function verifyObviusUser(req, res, next){ +function verifyObviusUser(req, res, next) { // First we ensure that the password and username parameters are provided. const password = req.param('password'); // TODO This is allowing for backwards compatibility if previous obvius meters are using the'email' parameter @@ -157,6 +157,7 @@ router.all('/', obviusLog, verifyObviusUser, async (req, res) => { return; } const conn = getConnection(); + const loadLogfilePromises = []; for (const fx of req.files) { log.info(`Received ${fx.fieldname}: ${fx.originalname}`); // Logfiles are always gzipped. @@ -168,9 +169,19 @@ router.all('/', obviusLog, verifyObviusUser, async (req, res) => { failure(req, res, `Unable to gunzip incoming buffer: ${err}`); return; } - loadLogfileToReadings(req.param('serialnumber'), ip, data, conn); + // The original code did not await for the Promise to finish. The new version + // allows the files to run in parallel (as before) but then wait for them all + // to finish before returning. + loadLogfilePromises.push(loadLogfileToReadings(req.param('serialnumber'), ip, data, conn)); } - success(req, res, 'Logfile Upload IS PROVISIONAL'); + // TODO This version returns an error. Should check all usage to be sure it is properly handled. + Promise.all(loadLogfilePromises).then(() => { + success(req, res, 'Logfile Upload IS PROVISIONAL'); + }).catch((err) => { + log.warn(`Logfile Upload had issues from ip: ${ip}`, err) + failure(req, res, 'Logfile Upload had issues'); + }); + // This return may not be needed. return; } diff --git a/src/server/services/addLogMsg.js b/src/server/services/addLogMsg.js new file mode 100644 index 000000000..303329061 --- /dev/null +++ b/src/server/services/addLogMsg.js @@ -0,0 +1,37 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +const fs = require('fs'); +const logFile = require('../config').logFile; +const LogMsg = require('../models/LogMsg'); +const { getConnection } = require('../db'); +const moment = require('moment'); + +async function addLogMsgToDB() { + try { + const data = await fs.promises.readFile(logFile, 'utf8'); + const logEntries = data.split('['); + const conn = getConnection(); + + for (const entry of logEntries) { + const logParts = entry.match(/(.*?)@(.*?)\] ([^\[]*)(?=\[|$)/s); + if (logParts) { + const [, logType, logTime, logMessage] = logParts; + const logMsg = new LogMsg(logType, logMessage, moment(logTime)); + try { + await logMsg.insert(conn); + } catch (err) { + console.error(`Failed to write log to database: ${err} (${err.stack})`); + } + } + } + console.log('Log migration completed successfully.'); + } catch (err) { + console.error(`Failed to migrate logs to database: ${err} (${err.stack})`); + } +} + +module.exports = { addLogMsgToDB }; \ No newline at end of file diff --git a/src/server/services/checkWebsiteStatus.js b/src/server/services/checkWebsiteStatus.js new file mode 100644 index 000000000..3c63ad65f --- /dev/null +++ b/src/server/services/checkWebsiteStatus.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { log } = require('../log'); +const WEBSITE_URL = process.argv[2]; + +async function checkWebsite() { + try { + const response = await fetch(WEBSITE_URL, { method: 'HEAD' }); + + if (!response.ok) { + const errorMessage = `The server at ${WEBSITE_URL} is down.`; + // Log the error using Logger class + log.error(errorMessage); + } + } catch (error) { + const errorMessage = `Failed to reach ${WEBSITE_URL}. Error: ${error.message}`; + log.error(errorMessage, error); + } +} + +checkWebsite(); diff --git a/src/server/sql/logmsg/create_log_types_enum.sql b/src/server/sql/logmsg/create_log_types_enum.sql new file mode 100644 index 000000000..64df5b2ab --- /dev/null +++ b/src/server/sql/logmsg/create_log_types_enum.sql @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +DO $$ BEGIN + CREATE TYPE log_msg_type AS ENUM('INFO', 'WARN', 'ERROR', 'DEBUG', 'SILENT'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; \ No newline at end of file diff --git a/src/server/sql/logmsg/create_logmsg_table.sql b/src/server/sql/logmsg/create_logmsg_table.sql new file mode 100644 index 000000000..1e6cfc5d8 --- /dev/null +++ b/src/server/sql/logmsg/create_logmsg_table.sql @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +--create logmsg table +CREATE TABLE IF NOT EXISTS logmsg ( + id SERIAL PRIMARY KEY, + log_type log_msg_type NOT NULL, + log_message TEXT NOT NULL, + log_time TIMESTAMP NOT NULL +); + +-- TODO Consider index optimization for queries \ No newline at end of file diff --git a/src/server/sql/logmsg/get_logmsgs_from_dates_and_type.sql b/src/server/sql/logmsg/get_logmsgs_from_dates_and_type.sql new file mode 100644 index 000000000..42f6806d3 --- /dev/null +++ b/src/server/sql/logmsg/get_logmsgs_from_dates_and_type.sql @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +-- Gets logs in table by date range. This is then ordered by time ascending. +SELECT + log_type, log_message, log_time +FROM logmsg +WHERE log_type = ANY (${logTypes}::log_msg_type[]) + AND log_time >= COALESCE(${startDate}, '-infinity'::TIMESTAMP) + AND log_time <= COALESCE(${endDate}, 'infinity'::TIMESTAMP) +ORDER BY log_time ASC +LIMIT ${logLimit}; \ No newline at end of file diff --git a/src/server/sql/logmsg/insert_new_logmsg.sql b/src/server/sql/logmsg/insert_new_logmsg.sql new file mode 100644 index 000000000..2c5726ca7 --- /dev/null +++ b/src/server/sql/logmsg/insert_new_logmsg.sql @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +--Inserts a new log into the table +INSERT INTO logmsg (log_type, log_message, log_time) +VALUES (${logType}, ${logMessage}, ${logTime}); diff --git a/src/server/test/web/obviusTest.js b/src/server/test/web/obviusTest.js index 4b21d698c..4566af7e2 100644 --- a/src/server/test/web/obviusTest.js +++ b/src/server/test/web/obviusTest.js @@ -7,7 +7,24 @@ const { chai, mocha, expect, app, testDB, testUser } = require('../common'); const User = require('../../models/User'); +const Configfile = require('../../models/obvius/Configfile'); const bcrypt = require('bcryptjs'); +const { insertUnits } = require('../../util/insertData'); +const Unit = require('../../models/Unit'); +const Meter = require('../../models/Meter.js'); +const { Console } = require('console'); + +//expected names and ids for obvius meters. +const expMeterNames = [ + 'mb-001.0', 'mb-001.1', 'mb-001.2', 'mb-001.3', 'mb-001.4', 'mb-001.5', 'mb-001.6', 'mb-001.7', + 'mb-001.8', 'mb-001.9', 'mb-001.10', 'mb-001.11', 'mb-001.12', 'mb-001.13', 'mb-001.14', 'mb-001.15', + 'mb-001.16', 'mb-001.17', 'mb-001.18', 'mb-001.19', 'mb-001.20', 'mb-001.21', 'mb-001.22', 'mb-001.23', + 'mb-001.24', 'mb-001.25', 'mb-001.26', 'mb-001.27' +]; + +const expMeterIDs = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28 +]; mocha.describe('Obvius API', () => { mocha.describe('upload: ', () => { @@ -58,5 +75,210 @@ mocha.describe('Obvius API', () => { } } }); + mocha.describe('obvius request modes', async () => { + mocha.beforeEach(async () => { + const conn = testDB.getConnection(); + // The kWh unit is not used in all tests but easier to just put in. + const unitData = [ + { + name: 'kWh', + identifier: '', + unitRepresent: Unit.unitRepresentType.QUANTITY, + secInRate: 3600, + typeOfUnit: Unit.unitType.UNIT, + suffix: '', + displayable: Unit.displayableType.ALL, + preferredDisplay: true, + note: 'OED created standard unit' + } + ]; + await insertUnits(unitData, false, conn); + }); + mocha.it('should reject requests without a mode', async () => { + const password = 'password'; + const hashedPassword = await bcrypt.hash(password, 10); + const obviusUser = new User(undefined, 'obivus@example.com', hashedPassword, User.role.OBVIUS); + await obviusUser.insert(conn); + obviusUser.password = password; + const res = await chai.request(app).post('/api/obvius').send({ username: obviusUser.username, password: obviusUser.password }); + //should respond with 406, not acceptable + expect(res).to.have.status(406); + //should also return expected message + expect(res.text).equals(`

\nRequest must include mode parameter.\n
\n`); + }); + mocha.it('should accept status requests', async () => { + const password = 'password'; + const hashedPassword = await bcrypt.hash(password, 10); + const obviusUser = new User(undefined, 'obivus@example.com', hashedPassword, User.role.OBVIUS); + await obviusUser.insert(conn); + obviusUser.password = password; + const requestMode = 'STATUS'; + const res = await chai.request(app).post('/api/obvius').send({ username: obviusUser.username, password: obviusUser.password, mode: requestMode }); + //should respond with 200, success + expect(res).to.have.status(200); + //should also return expected message + expect(res.text).equals("
\nSUCCESS\n
\n"); + }); + mocha.it('should accept valid logfile uploads', async () => { + const password = 'password'; + const hashedPassword = await bcrypt.hash(password, 10); + const obviusUser = new User(undefined, 'obvius@example.com', hashedPassword, User.role.OBVIUS); + await obviusUser.insert(conn); + obviusUser.password = password; + const logfileRequestMode = 'LOGFILEUPLOAD'; + + // Adapted from ../obvius/README.md + const logfilePath = 'src/server/test/web/obvius/mb-001.log.gz'; + + //the upload of a logfile is the subject of the test + const res = await chai.request(app) + .post('/api/obvius') + .field('username', obviusUser.username) + .field('password', obviusUser.password) + .field('mode', logfileRequestMode) + .field('serialnumber', 'mb-001') + .attach('files', logfilePath); + //should respond with 200, success + expect(res).to.have.status(200); + //should also return expected message + expect(res.text).equals("
\nSUCCESS\nLogfile Upload IS PROVISIONAL
\n"); + + }); + mocha.it('should accept valid config file uploads', async () => { + const password = 'password'; + const hashedPassword = await bcrypt.hash(password, 10); + const obviusUser = new User(undefined, 'obvius@example.com', hashedPassword, User.role.OBVIUS); + await obviusUser.insert(conn); + obviusUser.password = password; + const requestMode = 'CONFIGFILEUPLOAD'; + + // Adapted from ../obvius/README.md + const configFilePath = 'src/server/test/web/obvius/mb-001.ini.gz'; + + const res = await chai.request(app) + .post('/api/obvius') + .field('username', obviusUser.username) + .field('password', obviusUser.password) + .field('mode', requestMode) + .field('serialnumber', 'mb-001') + .field('modbusdevice', '1234') + .attach('files', configFilePath); + + //should respond with 200, success + expect(res).to.have.status(200); + //should also return expected message + expect(res.text).equals("
\nSUCCESS\nAcquired config log with (pseudo)filename mb-001-mb-1234.ini.
\n"); + }); + mocha.it('should return accurate config file manifests', async () => { + const password = 'password'; + const hashedPassword = await bcrypt.hash(password, 10); + const obviusUser = new User(undefined, 'obvius@example.com', hashedPassword, User.role.OBVIUS); + await obviusUser.insert(conn); + obviusUser.password = password; + const uploadRequestMode = 'CONFIGFILEUPLOAD'; + const manifestRequestMode = 'CONFIGFILEMANIFEST'; + const serialStart = 'mb-'; + const serialNumber = serialStart + '001'; + const modbusDevice = '1234' + + // Adapted from ../obvius/README.md + const configFilePath = 'src/server/test/web/obvius/mb-001.ini.gz'; + const upload = await chai.request(app) + .post('/api/obvius') + .field('username', obviusUser.username) + .field('password', obviusUser.password) + .field('mode', uploadRequestMode) + .field('serialnumber', serialNumber) + .field('modbusdevice', modbusDevice) + .attach('files', configFilePath); + + //logfile upload should respond with 200, success + expect(upload).to.have.status(200); + + const res = await chai.request(app) + .post('/api/obvius') + .field('username', obviusUser.username) + .field('password', obviusUser.password) + .field('mode', manifestRequestMode); + + //logfile request should respond with 200, success + expect(res).to.have.status(200); + + //get "all" config files to compare to response + const allConfigfiles = await Configfile.getAll(conn); + let response = ''; + for (f of allConfigfiles) { + response += `CONFIGFILE,${serialNumber}-${serialStart}${modbusDevice}.ini,${f.hash},${f.created.format('YYYY-MM-DD hh:mm:ss')}`; + } + + //the third line of the response should be the config file + expect(res.text.split("\n")[2]).equals(response); + + //config file uploads should create accurate meter objects + const allMeters = await Meter.getAll(conn); + + //mb-001.ini should make meters equal to expMeterNames.length + expect(allMeters.length).to.equal(expMeterNames.length); + + //these arrays should vary for different submeters + const meterNames = []; + const meterIDs = []; + + //flags for meter fields (.type, .displayable, .enabled) + const allMetersAreObvius = true; + const allMetersAreNotDisplayable = true; + const allMetersAreNotEnabled = true; + + for (const meter of allMeters) { + //populate arrays with varying values in ascending order + let currentName = meter.name; + let idx = currentName.split('.')[1]; + meterNames[parseInt(idx)] = meter.name; + meterIDs[meter.id - 1] = meter.id; + //ensure each meter is obvius, not displayable, and not enabled + if (meter.type != 'obvius') { + allMetersAreObvius = false; + } + if (meter.displayable != false) { + allMetersAreNotDisplayable = false; + } + if (meter.enabled != false) { + allMetersAreNotEnabled = false; + } + } + + //flags for comparison between expected arrays and actual arrays + let expectedNamesAreEqual = true; + let expectedIDsAreEqual = true; + + //error message for more descriptive failures + let allErrorMessagesNames = ""; + let allErrorMessagesIDs = ""; + + + //both arrays should be contain the same sequence of values + for (let i = 0; i < expMeterNames.length; i++) { + if (expMeterNames[i] != meterNames[i]) { + expectedNamesAreEqual = false; + allErrorMessagesNames += "Meter failed name comparison, Expected: " + expMeterNames[i] + " Actual: " + meterNames[i] + "\n"; + } + + if (expMeterIDs[i] != meterIDs[i]) { + expectedIDsAreEqual = false; + allErrorMessagesIDs += "Meter failed ID comparison. Expected: " + expMeterIDs[i] + " Actual: " + meterIDs[i] + "\n"; + + } + } + + //assertion for type, displayable, and enabled + expect(allMetersAreObvius).to.equal(true); + expect(allMetersAreNotDisplayable).to.equal(true); + expect(allMetersAreNotEnabled).to.equal(true); + + //expected arrays should equal actual arrays + expect(expectedNamesAreEqual).to.equal(true, allErrorMessagesNames); + expect(expectedIDsAreEqual).to.equal(true, allErrorMessagesIDs); + }); + }); }); -}); +}); \ No newline at end of file diff --git a/src/server/test/web/readingsCompareGroupQuantity.js b/src/server/test/web/readingsCompareGroupQuantity.js index e04c82196..f26ddf1ef 100644 --- a/src/server/test/web/readingsCompareGroupQuantity.js +++ b/src/server/test/web/readingsCompareGroupQuantity.js @@ -51,12 +51,37 @@ mocha.describe('readings API', () => { }); expectCompareToEqualExpected(res, expected, GROUP_ID); }); + mocha.it('CG3: 28 day shift end 2022-10-31 17:00:00 for 15 minute reading intervals and quantity units & kWh as kWh ', async () => { + await prepareTest(unitDatakWh, conversionDatakWh, meterDatakWhGroups, groupDatakWh); + // Get the unit ID since the DB could use any value. + const unitId = await getUnitId('kWh'); + const expected = [189951.689612281, 190855.90449004]; + // for compare, need the unitID, currentStart, currentEnd, shift + const res = await chai.request(app).get(`/api/compareReadings/groups/${GROUP_ID}`) + .query({ + curr_start: '2022-10-09 00:00:00', + curr_end: '2022-10-31 17:00:00', + shift: 'P28D', + graphicUnitId: unitId + }); + expectCompareToEqualExpected(res, expected, GROUP_ID); + }); + mocha.it('CG4: 1 day shift end 2022-11-01 00:00:00 (full day) for 15 minute reading intervals and quantity units & kWh as kWh', async () => { + await prepareTest(unitDatakWh, conversionDatakWh, meterDatakWhGroups, groupDatakWh); + // Get the unit ID since the DB could use any value. + const unitId = await getUnitId('kWh'); + const expected = [7820.41927336775, 8351.13117114892]; + // for compare, need the unitID, currentStart, currentEnd, shift + const res = await chai.request(app).get(`/api/compareReadings/groups/${GROUP_ID}`) + .query({ + curr_start: '2022-10-31 00:00:00', + curr_end: '2022-11-01 00:00:00', + shift: 'P1D', + graphicUnitId: unitId + }); + expectCompareToEqualExpected(res, expected, GROUP_ID); + }); - // Add CG3 here - - // Add CG4 here - - //Put readings with zero until 2022-11-01 15:00:00 mocha.it('CG5: 7 day shift end 2022-11-01 15:00:00 (beyond data) for 15 minute reading intervals and quantity units & kWh as kWh ', async () => { await prepareTest(unitDatakWh, conversionDatakWh, meterDatakWhGroups, groupDatakWh); // Get the unit ID since the DB could use any value.