From d0abe436edd20218fef110dd0719fd06ce9e806a Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 21 Aug 2024 22:55:20 +0100 Subject: [PATCH 01/26] Implement the initial uptime monitoring UI structure --- app/api/index.ts | 9 + .../Project/Alerts/View/ProjectAlertsView.tsx | 1 - app/pages/Project/View/ViewProject.tsx | 84 ++-- app/pages/Project/uptime/Settings.tsx | 414 ++++++++++++++++++ app/pages/Project/uptime/View.tsx | 223 ++++++++++ app/redux/constants/index.ts | 23 + app/redux/models/IUser.ts | 16 +- app/redux/models/Uptime.ts | 20 + app/redux/reducers/ui/index.ts | 2 + app/redux/reducers/ui/monitors.ts | 37 ++ app/redux/store/index.ts | 2 + app/routes/projects.$pid.uptime.create.tsx | 5 + .../projects.$pid.uptime.settings.$id.tsx | 5 + app/utils/routes.ts | 2 + public/locales/en.json | 11 + 15 files changed, 814 insertions(+), 40 deletions(-) create mode 100644 app/pages/Project/uptime/Settings.tsx create mode 100644 app/pages/Project/uptime/View.tsx create mode 100644 app/redux/models/Uptime.ts create mode 100644 app/redux/reducers/ui/monitors.ts create mode 100644 app/routes/projects.$pid.uptime.create.tsx create mode 100644 app/routes/projects.$pid.uptime.settings.$id.tsx diff --git a/app/api/index.ts b/app/api/index.ts index c2a533dd7..949d63521 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -984,6 +984,15 @@ export const deleteAlert = (id: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) +export const deleteMonitor = (pid: string, id: string) => + api + .delete(`project/${pid}/monitor/${id}`) + .then((response) => response.data) + .catch((error) => { + debug('%s', error) + throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message + }) + export const reGenerateCaptchaSecretKey = (pid: string) => api .post(`project/secret-gen/${pid}`) diff --git a/app/pages/Project/Alerts/View/ProjectAlertsView.tsx b/app/pages/Project/Alerts/View/ProjectAlertsView.tsx index adb7fe96b..9facec9e1 100644 --- a/app/pages/Project/Alerts/View/ProjectAlertsView.tsx +++ b/app/pages/Project/Alerts/View/ProjectAlertsView.tsx @@ -189,7 +189,6 @@ const ProjectAlerts = ({ projectId }: IProjectAlerts): JSX.Element => { const [isPaidFeatureOpened, setIsPaidFeatureOpened] = useState(false) const navigate = useNavigate() - // @ts-ignore const limits = PLAN_LIMITS[user?.planCode] || PLAN_LIMITS.trial const isLimitReached = authenticated && total >= limits?.maxAlerts diff --git a/app/pages/Project/View/ViewProject.tsx b/app/pages/Project/View/ViewProject.tsx index e358ccef8..aa7ffcc5c 100644 --- a/app/pages/Project/View/ViewProject.tsx +++ b/app/pages/Project/View/ViewProject.tsx @@ -21,6 +21,7 @@ import { BookmarkIcon, TrashIcon, PencilIcon, + ClockIcon, } from '@heroicons/react/24/outline' import cx from 'clsx' import dayjs from 'dayjs' @@ -182,6 +183,7 @@ import CountryDropdown from './components/CountryDropdown' import MetricCards, { MetricCard } from './components/MetricCards' import PerformanceMetricCards from './components/PerformanceMetricCards' import ProjectAlertsView from '../Alerts/View' +import Uptime from '../uptime/View' import UTMDropdown from './components/UTMDropdown' import TBPeriodSelector from './components/TBPeriodSelector' import { ISession } from './interfaces/session' @@ -578,7 +580,7 @@ const ViewProject = ({ try { const funnels = await getFunnels(id, projectPassword) - await updateProject(id, { + updateProject(id, { funnels, }) } catch (reason: any) { @@ -942,7 +944,6 @@ const ViewProject = ({ [t], ) - // tabs is a tabs for project const tabs: { id: string label: string @@ -997,6 +998,11 @@ const ViewProject = ({ label: t('dashboard.alerts'), icon: BellIcon, }, + { + id: PROJECT_TABS.uptime, + label: t('dashboard.uptime'), + icon: ClockIcon, + }, ...adminTabs, ] @@ -3695,6 +3701,7 @@ const ViewProject = ({ {/* Tabs selector */} {activeTab !== PROJECT_TABS.alerts && + activeTab !== PROJECT_TABS.uptime && (activeTab !== PROJECT_TABS.sessions || !activePSID) && (activeFunnel || activeTab !== PROJECT_TABS.funnels) && ( <> @@ -3870,45 +3877,43 @@ const ViewProject = ({ headless /> )} - {activeTab !== PROJECT_TABS.funnels && - activeTab !== PROJECT_TABS.sessions && - activeTab !== PROJECT_TABS.errors && ( - {}, - }, - ], - (el) => !!el, - )} - title={[]} - labelExtractor={(item) => item.label} - keyExtractor={(item) => item.label} - onSelect={(item, e) => { - if (item.lookingForMore) { - e?.stopPropagation() - window.open(MARKETPLACE_URL, '_blank') + {_includes([PROJECT_TABS.traffic, PROJECT_TABS.performance], activeTab) && ( + {}, + }, + ], + (el) => !!el, + )} + title={[]} + labelExtractor={(item) => item.label} + keyExtractor={(item) => item.label} + onSelect={(item, e) => { + if (item.lookingForMore) { + e?.stopPropagation() + window.open(MARKETPLACE_URL, '_blank') - return - } + return + } - trackCustom('DASHBOARD_EXPORT', { - type: item.label === t('project.asCSV') ? 'csv' : 'extension', - }) + trackCustom('DASHBOARD_EXPORT', { + type: item.label === t('project.asCSV') ? 'csv' : 'extension', + }) - item.onClick(panelsData, t) - }} - chevron='mini' - buttonClassName='!p-2 rounded-md hover:bg-white hover:shadow-sm dark:hover:bg-slate-800 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 focus:dark:ring-gray-200 focus:dark:border-gray-200' - headless - /> - )} + item.onClick(panelsData, t) + }} + chevron='mini' + buttonClassName='!p-2 rounded-md hover:bg-white hover:shadow-sm dark:hover:bg-slate-800 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 focus:dark:ring-gray-200 focus:dark:border-gray-200' + headless + /> + )}
)} + {activeTab === PROJECT_TABS.uptime && !isSharedProject && project?.isOwner && authenticated && ( + + )} {analyticsLoading && (activeTab === PROJECT_TABS.traffic || activeTab === PROJECT_TABS.performance) && ( )} diff --git a/app/pages/Project/uptime/Settings.tsx b/app/pages/Project/uptime/Settings.tsx new file mode 100644 index 000000000..2f2d4c0de --- /dev/null +++ b/app/pages/Project/uptime/Settings.tsx @@ -0,0 +1,414 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { useNavigate, useParams, useLocation, Link } from '@remix-run/react' +import { useTranslation, Trans } from 'react-i18next' +import { ExclamationTriangleIcon } from '@heroicons/react/24/outline' + +import _isEmpty from 'lodash/isEmpty' +import _size from 'lodash/size' +import _replace from 'lodash/replace' +import _find from 'lodash/find' +import _split from 'lodash/split' +import _keys from 'lodash/keys' +import _filter from 'lodash/filter' +import _reduce from 'lodash/reduce' +import _values from 'lodash/values' +import _findKey from 'lodash/findKey' +import _toNumber from 'lodash/toNumber' +import { clsx as cx } from 'clsx' +import Input from 'ui/Input' +import Button from 'ui/Button' +import Checkbox from 'ui/Checkbox' +import Modal from 'ui/Modal' +import { PROJECT_TABS, QUERY_CONDITION, QUERY_METRIC, QUERY_TIME } from 'redux/constants' +import { createAlert, updateAlert, deleteAlert, ICreateAlert } from 'api' +import { withAuthentication, auth } from 'hoc/protected' +import routes from 'utils/routes' +import Select from 'ui/Select' +import { IAlerts } from 'redux/models/IAlerts' +import { useDispatch, useSelector } from 'react-redux' +import { StateType } from 'redux/store' +import { errorsActions } from 'redux/reducers/errors' +import { alertsActions } from 'redux/reducers/alerts' +import UIActions from 'redux/reducers/ui' + +const INTEGRATIONS_LINK = `${routes.user_settings}#integrations` + +const UptimeSettings = (): JSX.Element => { + const { alerts, total } = useSelector((state: StateType) => state.ui.alerts) + const { user, loading } = useSelector((state: StateType) => state.auth) + + const dispatch = useDispatch() + + const navigate = useNavigate() + const { id, pid } = useParams() + const { pathname } = useLocation() + const { t } = useTranslation('common') + const isSettings: boolean = + !_isEmpty(id) && _replace(_replace(routes.alert_settings, ':id', id as string), ':pid', pid as string) === pathname + const alert = useMemo(() => _find(alerts, { id }), [alerts, id]) + const [form, setForm] = useState>({ + pid, + name: '', + queryTime: QUERY_TIME.LAST_1_HOUR, + queryCondition: QUERY_CONDITION.GREATER_THAN, + queryMetric: QUERY_METRIC.PAGE_VIEWS, + active: true, + queryCustomEvent: '', + }) + const [validated, setValidated] = useState(false) + const [errors, setErrors] = useState<{ + [key: string]: string + }>({}) + const [beenSubmitted, setBeenSubmitted] = useState(false) + const [showModal, setShowModal] = useState(false) + + const showError = (message: string) => dispatch(errorsActions.genericError({ message })) + const generateAlerts = (message: string) => dispatch(alertsActions.generateAlerts({ message, type: 'success' })) + const setProjectAlerts = (alerts: IAlerts[]) => dispatch(UIActions.setProjectAlerts(alerts)) + const setProjectAlertsTotal = (total: number) => dispatch(UIActions.setProjectAlertsTotal({ total })) + + const isIntegrationLinked = useMemo(() => { + if (_isEmpty(user)) { + return false + } + + return Boolean( + (user.telegramChatId && user.isTelegramChatIdConfirmed) || user.slackWebhookUrl || user.discordWebhookUrl, + ) + }, [user]) + + const queryTimeTMapping: { + [key: string]: string + } = useMemo(() => { + const values = _values(QUERY_TIME) + + return _reduce( + values, + (prev, curr) => { + const [, amount, metric] = _split(curr, '_') + let translated + + if (metric === 'minutes') { + translated = t('alert.xMinutes', { amount }) + } + + if (metric === 'hour') { + translated = t('alert.xHour', { amount }) + } + + if (metric === 'hours') { + translated = t('alert.xHours', { amount }) + } + + return { + ...prev, + [curr]: translated, + } + }, + {}, + ) + }, [t]) + + const queryConditionTMapping: { + [key: string]: string + } = useMemo(() => { + const values = _values(QUERY_CONDITION) + + return _reduce( + values, + (prev, curr) => ({ + ...prev, + [curr]: t(`alert.conditions.${curr}`), + }), + {}, + ) + }, [t]) + + const queryMetricTMapping: { + [key: string]: string + } = useMemo(() => { + const values = _values(QUERY_METRIC) + + return _reduce( + values, + (prev, curr) => ({ + ...prev, + [curr]: t(`alert.metrics.${curr}`), + }), + {}, + ) + }, [t]) + + useEffect(() => { + if (!_isEmpty(alert)) { + setForm(alert) + } + }, [alert]) + + const validate = () => { + const allErrors: { + [key: string]: string + } = {} + + if (_isEmpty(form.name) || _size(form.name) < 3) { + allErrors.name = t('alert.noNameError') + } + + if (form.queryMetric === QUERY_METRIC.CUSTOM_EVENTS && _isEmpty(form.queryCustomEvent)) { + allErrors.queryCustomEvent = t('alert.noCustomEventError') + } + + if (Number.isNaN(_toNumber(form.queryValue))) { + allErrors.queryValue = t('alert.queryValueError') + } + + const valid = _isEmpty(_keys(allErrors)) + + setErrors(allErrors) + setValidated(valid) + } + + const onSubmit = (data: Partial) => { + if (isSettings) { + updateAlert(id as string, data) + .then((res) => { + navigate(`/projects/${pid}?tab=${PROJECT_TABS.alerts}`) + setProjectAlerts([..._filter(alerts, (a) => a.id !== id), res]) + generateAlerts(t('alertsSettings.alertUpdated')) + }) + .catch((err) => { + showError(err.message || err || 'Something went wrong') + }) + } else { + createAlert(data as ICreateAlert) + .then((res) => { + navigate(`/projects/${pid}?tab=${PROJECT_TABS.alerts}`) + setProjectAlerts([...alerts, res]) + setProjectAlertsTotal(total + 1) + generateAlerts(t('alertsSettings.alertCreated')) + }) + .catch((err) => { + showError(err.message || err || 'Something went wrong') + }) + } + } + + const onDelete = () => { + if (!id) { + showError('Something went wrong') + return + } + + deleteAlert(id) + .then(() => { + setProjectAlerts(_filter(alerts, (a) => a.id !== id)) + setProjectAlertsTotal(total - 1) + navigate(`/projects/${pid}?tab=${PROJECT_TABS.alerts}`) + generateAlerts(t('alertsSettings.alertDeleted')) + }) + .catch((err) => { + showError(err.message || err || 'Something went wrong') + }) + } + + const onCancel = () => { + navigate(`/projects/${pid}?tab=${PROJECT_TABS.alerts}`) + } + + useEffect(() => { + validate() + }, [form]) // eslint-disable-line + + const handleInput = (event: React.ChangeEvent) => { + const { target } = event + + setForm((prevForm) => ({ + ...prevForm, + [target.name]: target.value, + })) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + e.stopPropagation() + setBeenSubmitted(true) + + if (validated) { + onSubmit(form) + } + } + + const title = isSettings + ? t('alert.settingsOf', { + name: form.name, + }) + : t('alert.create') + + return ( +
+
+

{title}

+ {!loading && !isIntegrationLinked && ( +
+ + , + }} + /> +
+ )} + + + setForm((prev) => ({ + ...prev, + active: checked, + })) + } + name='active' + className='mt-4' + label={t('alert.enabled')} + hint={t('alert.enabledHint')} + /> +
+ + )} +
+ +
+ - - setForm((prev) => ({ - ...prev, - active: checked, - })) - } - name='active' - className='mt-4' - label={t('alert.enabled')} - hint={t('alert.enabledHint')} - />
- )} -
- + + + + + + -
- + + {_map(INTERVALS_IN_SECONDS, (interval, index) => { + if (!_includes(INTERVALS_ALLOWED_DATALIST, interval)) { + return +
+ ) +} + +const MAX_RETRIES = 100 + const UptimeSettings = (): JSX.Element => { const { monitors, total } = useSelector((state: StateType) => state.ui.monitors) @@ -39,7 +153,10 @@ const UptimeSettings = (): JSX.Element => { const navigate = useNavigate() const { id, pid } = useRequiredParams<{ id: string; pid: string }>() const { pathname } = useLocation() - const { t } = useTranslation('common') + const { + t, + i18n: { language }, + } = useTranslation('common') const isSettings = !_isEmpty(id) && _replace(_replace(routes.uptime_settings, ':id', id as string), ':pid', pid as string) === pathname const monitor = useMemo(() => _find(monitors, { id }), [monitors, id]) @@ -89,6 +206,45 @@ const UptimeSettings = (): JSX.Element => { allErrors.name = t('alert.noNameError') } + if (_isEmpty(form.url)) { + allErrors.url = t('apiNotifications.inputCannotBeEmpty') + } + + if (!isValidUrl(form.url!)) { + allErrors.url = t('monitor.error.urlInvalid') + } + + const retries = Number(form.retries) + + if (_isNaN(retries)) { + allErrors.retries = t('apiNotifications.enterACorrectNumber') + } + + if (retries <= 0) { + allErrors.retries = t('apiNotifications.numberCantBeNegative') + } + + if (retries > MAX_RETRIES) { + allErrors.retries = t('apiNotifications.numberCantBeBigger', { + max: MAX_RETRIES, + }) + } + + const acceptedStatusCodes = _split(form.acceptedStatusCodes, ',') + + if (_isEmpty(acceptedStatusCodes)) { + allErrors.acceptedStatusCodes = t('apiNotifications.inputCannotBeEmpty') + } + + if ( + acceptedStatusCodes.some((statusCode) => { + const trimmedStatusCode = Number(statusCode.trim()) + return !_isNaN(trimmedStatusCode) && trimmedStatusCode >= 100 && trimmedStatusCode <= 599 + }) + ) { + allErrors.acceptedStatusCodes = t('monitor.error.acceptedStatusCodesNotValid') + } + const valid = _isEmpty(_keys(allErrors)) setErrors(allErrors) @@ -213,33 +369,37 @@ const UptimeSettings = (): JSX.Element => { onChange={handleInput} error={beenSubmitted ? errors.url : null} /> - - { onChange={handleInput} error={beenSubmitted ? errors.acceptedStatusCodes : null} /> - {isSettings ? (
diff --git a/public/locales/en.json b/public/locales/en.json index 1e3900e4c..7a1092b2a 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -26,6 +26,7 @@ "integrationSaveError": "Failed to save integration, make sure you've entered a valid input", "enterACorrectNumber": "Please enter a correct number", "numberCantBeNegative": "The number cannot be negative", + "numberCantBeBigger": "The number cannot be bigger than {{max}}", "forecastNumberCantBeBigger": "The forecast number cannot be bigger than {{max}} for the selected frequency", "invalidToken": "Invalid token", "transferRequestSent": "Transfer request sent", @@ -740,14 +741,22 @@ "form": { "name": "Monitor name", "type": "Monitor type", - "url": "Monitor URL", + "url": "URL to monitor", "interval": "Monitor interval", + "intervalHint": "We will check the URL every {{intervalTranslated}}", "retries": "Monitor retries", + "retriesHint": "We will retry the URL {{retries}} times before marking it as failed", "retryInterval": "Monitor retry interval", + "retryIntervalHint": "We will retry the URL every {{retryIntervalTranslated}}", "timeout": "Monitor timeout", + "timeoutHint": "We will cancel the request to the URL if it takes longer than {{x}} seconds and mark it as failed", "acceptedStatusCodes": "Monitor accepted status codes (comma-separated)", "description": "Monitor description" }, + "error": { + "urlInvalid": "Please enter a valid URL", + "acceptedStatusCodesNotValid": "Some of the values provided are not valid HTTP status codes" + }, "backToMonitors": "Back to monitors list", "metrics": { "avg": "Avg. response time", From 9a62c36118184d1c38eae9e29bd24942fe3e6fe4 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sun, 25 Aug 2024 23:50:22 +0100 Subject: [PATCH 22/26] Allow shared project users & unauthenticated people to view public uptimes --- app/api/index.ts | 17 ++++ app/pages/Project/View/ViewProject.tsx | 6 +- app/pages/Project/uptime/View.tsx | 93 +++++++++++++------ .../uptime/components/NoMonitorDetails.tsx | 17 ++++ public/locales/en.json | 2 + 5 files changed, 104 insertions(+), 31 deletions(-) create mode 100644 app/pages/Project/uptime/components/NoMonitorDetails.tsx diff --git a/app/api/index.ts b/app/api/index.ts index 84fbf78e7..54dec9a89 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -1004,6 +1004,23 @@ export const getAllMonitors = (take: number = DEFAULT_MONITORS_TAKE, skip: numbe throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) +export const getProjectMonitors = (projectId: string, take: number = DEFAULT_MONITORS_TAKE, skip: number = 0) => + api + .get(`project/${projectId}/monitors?take=${take}&skip=${skip}`) + .then( + ( + response, + ): { + results: Monitor[] + total: number + page_total: number + } => response.data, + ) + .catch((error) => { + debug('%s', error) + throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message + }) + export const getMonitorOverallStats = ( pid: string, monitorIds: string[], diff --git a/app/pages/Project/View/ViewProject.tsx b/app/pages/Project/View/ViewProject.tsx index c8ebdaae0..fd21a23b3 100644 --- a/app/pages/Project/View/ViewProject.tsx +++ b/app/pages/Project/View/ViewProject.tsx @@ -3679,7 +3679,7 @@ const ViewProject = ({ ) } - if (!project.isDataExists && activeTab !== PROJECT_TABS.errors && !analyticsLoading) { + if (!project.isDataExists && !_includes([PROJECT_TABS.errors, PROJECT_TABS.uptime], activeTab) && !analyticsLoading) { return ( <> {!embedded &&
} @@ -4752,9 +4752,7 @@ const ViewProject = ({ {activeTab === PROJECT_TABS.alerts && !isSharedProject && project?.isOwner && authenticated && ( )} - {activeTab === PROJECT_TABS.uptime && !isSharedProject && project?.isOwner && authenticated && ( - - )} + {activeTab === PROJECT_TABS.uptime && } {analyticsLoading && (activeTab === PROJECT_TABS.traffic || activeTab === PROJECT_TABS.performance) && ( )} diff --git a/app/pages/Project/uptime/View.tsx b/app/pages/Project/uptime/View.tsx index b7a238cc5..246520534 100644 --- a/app/pages/Project/uptime/View.tsx +++ b/app/pages/Project/uptime/View.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import bb from 'billboard.js' import cx from 'clsx' import _map from 'lodash/map' @@ -27,13 +27,14 @@ import { PLAN_LIMITS, tbPeriodPairs, UPTIME_PERIOD_PAIRS } from 'redux/constants import UIActions from 'redux/reducers/ui' import { alertsActions } from 'redux/reducers/alerts' import { errorsActions } from 'redux/reducers/errors' -import { deleteMonitor as deleteMonitorApi, getMonitorOverallStats, getMonitorStats } from 'api' +import { deleteMonitor as deleteMonitorApi, getMonitorOverallStats, getMonitorStats, getProjectMonitors } from 'api' import { StateType } from 'redux/store' import { Monitor, MonitorOverallObject } from 'redux/models/Uptime' import { useViewProjectContext } from '../View/ViewProject' import { getFormatDate, getSettingsUptime } from '../View/ViewProject.helpers' import TBPeriodSelector from '../View/components/TBPeriodSelector' import { MetricCardsUptime } from '../View/components/MetricCards' +import NoMonitorEvents from './components/NoMonitorDetails' // const Separator = () => ( // @@ -162,7 +163,8 @@ const Uptime = (): JSX.Element => { const [isPaidFeatureOpened, setIsPaidFeatureOpened] = useState(false) const [activeMonitor, setActiveMonitor] = useState<{ monitor: Monitor - overall: MonitorOverallObject + overall?: MonitorOverallObject + isFailed?: boolean } | null>(null) const [isMonitorLoading, setIsMonitorLoading] = useState(false) const navigate = useNavigate() @@ -172,10 +174,27 @@ const Uptime = (): JSX.Element => { const limits = PLAN_LIMITS[user?.planCode] || PLAN_LIMITS.trial const isLimitReached = authenticated && total >= limits?.maxMonitors - const projectMonitors = useMemo(() => { - if (loading) return [] - return _filter(monitors, (monitor) => monitor.projectId === projectId) - }, [projectId, monitors, loading]) + const [projectMonitors, setProjectMonitors] = useState([]) + const [monitorFilterLoaded, setMonitorFilterLoaded] = useState(false) + + useEffect(() => { + if (loading || !_isEmpty(projectMonitors) || monitorFilterLoaded) { + return + } + + const filteredMonitors = _filter(monitors, (monitor) => monitor.projectId === projectId) + + if (!_isEmpty(filteredMonitors)) { + setProjectMonitors(filteredMonitors) + setMonitorFilterLoaded(true) + return + } + + getProjectMonitors(projectId).then(({ results }) => { + setProjectMonitors(results) + setMonitorFilterLoaded(true) + }) + }, [projectId, monitors, loading, projectMonitors, monitorFilterLoaded]) const handleNewMonitor = () => { if (isLimitReached) { @@ -224,7 +243,7 @@ const Uptime = (): JSX.Element => { const loadMonitorData = useCallback( async (monitor: Monitor) => { - if (isMonitorLoading || !projectId || isLoading) { + if (!monitorFilterLoaded || isMonitorLoading || !projectId || isLoading) { return } @@ -258,6 +277,7 @@ const Uptime = (): JSX.Element => { setActiveMonitor({ monitor, overall: overallStats[monitor.id], + isFailed: false, }) const { chart } = monitorStats @@ -272,6 +292,10 @@ const Uptime = (): JSX.Element => { setIsMonitorLoading(false) } catch (reason) { + setActiveMonitor({ + monitor, + isFailed: true, + }) setIsMonitorLoading(false) console.error('[ERROR](loadMonitorData) Loading monitor data failed') @@ -284,6 +308,7 @@ const Uptime = (): JSX.Element => { period, dateRange, isLoading, + monitorFilterLoaded, isMonitorLoading, projectId, timeBucket, @@ -343,12 +368,16 @@ const Uptime = (): JSX.Element => { {t('monitor.backToMonitors')} -
-
- + {activeMonitor.isFailed ? ( + + ) : ( +
+
+ +
+
-
-
+ )} ) } @@ -356,28 +385,38 @@ const Uptime = (): JSX.Element => { return (
- {loading &&
{t('common.loading')}
} - {!loading && _isEmpty(projectMonitors) && ( + {(loading || !monitorFilterLoaded) &&
{t('common.loading')}
} + {!loading && monitorFilterLoaded && _isEmpty(projectMonitors) && (

{t('dashboard.uptime')}

{t('dashboard.uptimeDesc')}

- + {authenticated ? ( + + ) : ( + + {t('common.getStarted')} + + )}
)} - {!loading && !_isEmpty(projectMonitors) && ( + {!loading && monitorFilterLoaded && !_isEmpty(projectMonitors) && (
    {_map(projectMonitors, (monitor) => ( diff --git a/app/pages/Project/uptime/components/NoMonitorDetails.tsx b/app/pages/Project/uptime/components/NoMonitorDetails.tsx new file mode 100644 index 000000000..d90dae6ea --- /dev/null +++ b/app/pages/Project/uptime/components/NoMonitorDetails.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' + +const NoMonitorEvents = () => { + const { t } = useTranslation('common') + + return ( +
    +
    +

    {t('monitor.noEvents')}

    +

    {t('monitor.noEventsDesc')}

    +
    +
    + ) +} + +export default NoMonitorEvents diff --git a/public/locales/en.json b/public/locales/en.json index 7a1092b2a..f8b44d60d 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -738,6 +738,8 @@ "monitorDeleted": "Uptime monitor deleted", "qDelete": "Delete the monitor?", "deleteHint": "This action is irreversible.\nThe uptime monitor and all the data related to it will be deleted from our servers.", + "noEvents": "No monitor events found", + "noEventsDesc": "No events have been recorded for the specified period, please try a different period.", "form": { "name": "Monitor name", "type": "Monitor type", From 16803607064a188b1f86a992e92f38deba390281 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sun, 25 Aug 2024 23:58:52 +0100 Subject: [PATCH 23/26] Change monitor status on timebucket / period updates --- app/pages/Project/uptime/View.tsx | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/app/pages/Project/uptime/View.tsx b/app/pages/Project/uptime/View.tsx index 246520534..90a94f726 100644 --- a/app/pages/Project/uptime/View.tsx +++ b/app/pages/Project/uptime/View.tsx @@ -318,6 +318,12 @@ const Uptime = (): JSX.Element => { ], ) + useEffect(() => { + if (activeMonitor) { + loadMonitorData(activeMonitor.monitor) + } + }, [period, dateRange, timeBucket]) + if (activeMonitor) { return ( <> @@ -369,13 +375,31 @@ const Uptime = (): JSX.Element => { {t('monitor.backToMonitors')} {activeMonitor.isFailed ? ( - + <> + + {isMonitorLoading && ( +
    +
    +
    +
    +
    +
    + )} + ) : (
    + {isMonitorLoading && ( +
    +
    +
    +
    +
    +
    + )}
    )} From f48b9818da19e34ae153f0fb1038a8061bc08c24 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 26 Aug 2024 00:00:41 +0100 Subject: [PATCH 24/26] lint fix --- app/pages/Project/uptime/View.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/pages/Project/uptime/View.tsx b/app/pages/Project/uptime/View.tsx index 90a94f726..c7397ed27 100644 --- a/app/pages/Project/uptime/View.tsx +++ b/app/pages/Project/uptime/View.tsx @@ -322,6 +322,7 @@ const Uptime = (): JSX.Element => { if (activeMonitor) { loadMonitorData(activeMonitor.monitor) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [period, dateRange, timeBucket]) if (activeMonitor) { From c32fc043dae29b4f870c5d199cefe4be0cf55e56 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 26 Aug 2024 00:09:28 +0100 Subject: [PATCH 25/26] Treat uptime chart data as time --- app/pages/Project/View/ViewProject.helpers.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/pages/Project/View/ViewProject.helpers.tsx b/app/pages/Project/View/ViewProject.helpers.tsx index 4eedc222c..a5f1a7e04 100644 --- a/app/pages/Project/View/ViewProject.helpers.tsx +++ b/app/pages/Project/View/ViewProject.helpers.tsx @@ -1276,7 +1276,8 @@ const getSettingsUptime = ( }, y: { tick: { - format: (d: number) => nFormatter(d, 1), + // @ts-expect-error + format: (d: string) => getStringFromTime(getTimeFromSeconds(d), true), }, show: true, inner: true, @@ -1299,7 +1300,7 @@ const getSettingsUptime = (
    ${el.name}
    - ${el.value} + ${getStringFromTime(getTimeFromSeconds(el.value), true)} `, ).join('')}` From b5d0a076621bc2a268b99749d4330b27ae5a8c3c Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 26 Aug 2024 00:12:10 +0100 Subject: [PATCH 26/26] Use useRequiredParams instead of useParams and restrict uptime PIDs for testing --- app/pages/Project/View/ViewProject.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/app/pages/Project/View/ViewProject.tsx b/app/pages/Project/View/ViewProject.tsx index fd21a23b3..96932bf59 100644 --- a/app/pages/Project/View/ViewProject.tsx +++ b/app/pages/Project/View/ViewProject.tsx @@ -13,7 +13,7 @@ import React, { } from 'react' import { ClientOnly } from 'remix-utils/client-only' import useSize from 'hooks/useSize' -import { useNavigate, useParams, Link } from '@remix-run/react' +import { useNavigate, Link } from '@remix-run/react' import bb from 'billboard.js' import { ArrowDownTrayIcon, @@ -223,6 +223,7 @@ import { import { trackCustom } from 'utils/analytics' import AddAViewModal from './components/AddAViewModal' import CustomMetrics from './components/CustomMetrics' +import { useRequiredParams } from 'hooks/useRequiredParams' const SwetrixSDK = require('@swetrix/sdk') const CUSTOM_EV_DROPDOWN_MAX_VISIBLE_LENGTH = 32 @@ -374,13 +375,7 @@ const ViewProject = ({ // dashboardRef is a ref for dashboard div const dashboardRef = useRef(null) - // { id } is a project id from url - // @ts-ignore - const { - id, - }: { - id: string - } = useParams() + const { id } = useRequiredParams<{ id: string }>() // history is a history from react-router-dom const navigate = useNavigate() @@ -1044,20 +1039,20 @@ const ViewProject = ({ label: t('dashboard.alerts'), icon: BellIcon, }, - { + ['79eF2Z9rNNvv', 'STEzHcB1rALV'].includes(id) && { id: PROJECT_TABS.uptime, label: t('dashboard.uptime'), icon: ClockIcon, }, ...adminTabs, - ] + ].filter((x) => !!x) if (projectQueryTabs && projectQueryTabs.length) { return _filter(newTabs, (tab) => _includes(projectQueryTabs, tab.id)) } return newTabs - }, [t, projectQueryTabs, allowedToManage]) + }, [t, id, projectQueryTabs, allowedToManage]) // activeTabLabel is a label for active tab. Using for title in dropdown const activeTabLabel = useMemo(() => _find(tabs, (tab) => tab.id === activeTab)?.label, [tabs, activeTab])