Add badge to alerts (#593)

* first pass at alert notifications

* sp alerts

* add all accounts alerts

* fix polling timer

* update style

* show count and change polling to 60s

* check alerts on login

* Delete settings.local.json

* poll for max 10 results in 30days and use last viewed

* make polling interval configurable

uses VITE_APP_ALERT_POLL_INTERVAL
default is 60s
setting to 0 will disable polling

* don't sent days and start

* add to .env
This commit is contained in:
Sam Machin
2026-03-30 15:24:23 +01:00
committed by GitHub
parent f30c2759fc
commit dd57a0a41a
9 changed files with 168 additions and 7 deletions
+3 -1
View File
@@ -33,4 +33,6 @@
## enable lazy loading for phone numbers (improves performance when managing large quantities)
# VITE_APP_ENABLE_PHONE_NUMBER_LAZY_LOAD=true
# hides controlls to add Carrier and Phone number from non Admin/SP Users (also need to set flag on API server to block API calls)
#VITE_ADMIN_CARRIER=1
#VITE_ADMIN_CARRIER=1
# Controls alert polling interval time in seconds, default 60, set to 0 to disable polling
#VITE_APP_ALERT_POLL_INTERVAL=60
+13
View File
@@ -920,6 +920,19 @@ export const getAlerts = (sid: string, query: Partial<PageQuery>) => {
);
};
export const getServiceProviderAlerts = (
sid: string,
query: Partial<PageQuery>,
) => {
const qryStr = getQuery<Partial<PageQuery>>(query);
return getFetch<PagedResponse<Alert>>(
import.meta.env.DEV
? `${DEV_BASE_URL}/ServiceProviders/${sid}/Alerts?${qryStr}`
: `${API_SERVICE_PROVIDERS}/${sid}/Alerts?${qryStr}`,
);
};
export const getPrice = () => {
return getFetch<PriceInfo[]>(API_PRICE);
};
+10 -1
View File
@@ -12,6 +12,7 @@ import {
setActiveSP,
} from "src/store/localStore";
import { postServiceProviders } from "src/api";
import { useAlertStatus } from "src/hooks/useAlertStatus";
import type { NaviItem } from "./items";
@@ -37,9 +38,10 @@ type ItemProps = CommonProps & {
item: NaviItem;
user?: UserData;
lcr?: Lcr;
badge?: number;
};
const Item = ({ item, user, lcr, handleMenu }: ItemProps) => {
const Item = ({ item, user, lcr, handleMenu, badge }: ItemProps) => {
const location = useLocation();
const active = location.pathname.includes(item.route(user));
@@ -52,6 +54,9 @@ const Item = ({ item, user, lcr, handleMenu }: ItemProps) => {
>
<item.icon />
<span>{item.label}</span>
{badge !== undefined && badge > 0 && (
<span className="navi__badge">{badge >= 10 ? "9+" : badge}</span>
)}
</Link>
</li>
);
@@ -71,10 +76,13 @@ export const Navi = ({
const accessControl = useSelectState("accessControl");
const serviceProviders = useSelectState("serviceProviders");
const currentServiceProvider = useSelectState("currentServiceProvider");
const unreadAlerts = useSelectState("unreadAlerts");
const [sid, setSid] = useState("");
const [modal, setModal] = useState(false);
const [name, setName] = useState("");
useAlertStatus();
const naviByoFiltered = useMemo(() => {
return naviByo.filter(
(item) => !item.acl || (item.acl && accessControl[item.acl]),
@@ -213,6 +221,7 @@ export const Navi = ({
user={user}
item={item}
handleMenu={handleMenu}
badge={item.label === "Alerts" ? unreadAlerts : undefined}
/>
);
})}
+16
View File
@@ -129,6 +129,22 @@
}
}
&__badge {
margin-left: auto;
background-color: ui-vars.$jambonz;
color: ui-vars.$white;
font-size: 11px;
font-weight: 600;
line-height: 1;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
}
&__byo {
margin-top: auto;
color: ui-vars.$grey;
+29 -5
View File
@@ -2,13 +2,17 @@ import React, { useEffect, useMemo, useState } from "react";
import { ButtonGroup, H1, M, MS } from "@jambonz/ui-kit";
import dayjs from "dayjs";
import { getAlerts, useServiceProviderData } from "src/api";
import {
getAlerts,
getServiceProviderAlerts,
useServiceProviderData,
} from "src/api";
import {
DATE_SELECTION,
PER_PAGE_SELECTION,
USER_ACCOUNT,
} from "src/api/constants";
import { useSelectState } from "src/store";
import { useSelectState, useDispatch } from "src/store";
import { hasLength, hasValue } from "src/utils";
import {
AccountFilter,
@@ -25,13 +29,16 @@ import {
getAccountFilter,
getQueryFilter,
setLocation,
setAlertsLastViewed,
} from "src/store/localStore";
import AlertDetailItem from "./alert-detail-item";
import { useToast } from "src/components/toast/toast-provider";
export const Alerts = () => {
const { toastError } = useToast();
const dispatch = useDispatch();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
const [dateFilter, setDateFilter] = useState("today");
@@ -57,7 +64,18 @@ export const Alerts = () => {
: { days: Number(dateFilter) }),
};
getAlerts(accountSid, payload)
const fetchAlerts = accountSid
? getAlerts(accountSid, payload)
: currentServiceProvider?.service_provider_sid
? getServiceProviderAlerts(
currentServiceProvider.service_provider_sid,
payload,
)
: null;
if (!fetchAlerts) return;
fetchAlerts
.then(({ json }) => {
setAlerts(json.data);
setAlertsTotal(json.total);
@@ -78,16 +96,21 @@ export const Alerts = () => {
}
}, [accountSid]);
useEffect(() => {
setAlertsLastViewed(new Date().toISOString());
dispatch({ type: "unreadAlerts", payload: 0 });
}, []);
useEffect(() => {
setLocation();
if (user?.account_sid && user.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
}
if (accountSid) {
if (accountSid || currentServiceProvider?.service_provider_sid) {
handleFilterChange();
}
}, [user, accountSid, pageNumber, dateFilter]);
}, [user, accountSid, pageNumber, dateFilter, currentServiceProvider]);
/** Reset page number when filters change */
useEffect(() => {
@@ -104,6 +127,7 @@ export const Alerts = () => {
<AccountFilter
account={[accountSid, setAccountSid]}
accounts={accounts}
defaultOption
/>
</ScopedAccess>
<SelectFilter
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useRef, useCallback } from "react";
import { getAlerts, getServiceProviderAlerts } from "src/api";
import { useSelectState, useDispatch } from "src/store";
import { getAlertsLastViewed } from "src/store/localStore";
import { Scope } from "src/store/types";
const DEFAULT_POLL_INTERVAL = 60; // seconds
const envPollInterval = import.meta.env.VITE_APP_ALERT_POLL_INTERVAL;
const POLL_INTERVAL =
envPollInterval !== undefined
? Number(envPollInterval) * 1000
: DEFAULT_POLL_INTERVAL * 1000;
export const useAlertStatus = () => {
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const dispatch = useDispatch();
const dispatchRef = useRef(dispatch);
useEffect(() => {
dispatchRef.current = dispatch;
}, [dispatch]);
const checkAlerts = useCallback(() => {
const lastViewed = getAlertsLastViewed();
const query = {
page: 1,
count: 10,
...(lastViewed ? { start: lastViewed } : { days: 30 }),
};
// Account-scoped users: check their own account
if (user?.access === Scope.account && user.account_sid) {
getAlerts(user.account_sid, query)
.then(({ json }) => {
dispatchRef.current({ type: "unreadAlerts", payload: json.total });
})
.catch(() => {});
return;
}
// Admin/SP users: check all alerts under the service provider
if (currentServiceProvider?.service_provider_sid) {
getServiceProviderAlerts(
currentServiceProvider.service_provider_sid,
query,
)
.then(({ json }) => {
dispatchRef.current({ type: "unreadAlerts", payload: json.total });
})
.catch(() => {});
}
}, [
user?.access,
user?.account_sid,
currentServiceProvider?.service_provider_sid,
]);
// Check immediately when user or SP changes
useEffect(() => {
checkAlerts();
}, [checkAlerts]);
// Set up polling interval (stable, doesn't restart on state changes)
const checkAlertsRef = useRef(checkAlerts);
useEffect(() => {
checkAlertsRef.current = checkAlerts;
}, [checkAlerts]);
useEffect(() => {
if (!POLL_INTERVAL) return;
const interval = setInterval(() => checkAlertsRef.current(), POLL_INTERVAL);
return () => clearInterval(interval);
}, []);
};
+2
View File
@@ -29,6 +29,7 @@ export const initialState: State = {
hasMSTeamsFqdn: false,
},
serviceProviders: [],
unreadAlerts: 0,
};
const reducer: React.Reducer<State, Action<keyof State>> = (state, action) => {
@@ -36,6 +37,7 @@ const reducer: React.Reducer<State, Action<keyof State>> = (state, action) => {
case "user":
case "lcr":
case "toast":
case "unreadAlerts":
return genericAction(state, action);
case "serviceProviders":
return serviceProvidersAction(state, action);
+14
View File
@@ -100,6 +100,20 @@ export const removeRootDomain = () => {
return localStorage.removeItem(rootDomainKey);
};
/**
* Methods to get/set the alerts last viewed timestamp from local storage
*/
const storeAlertsLastViewed = "alertsLastViewed";
export const getAlertsLastViewed = () => {
return localStorage.getItem(storeAlertsLastViewed) || "";
};
export const setAlertsLastViewed = (timestamp: string) => {
localStorage.setItem(storeAlertsLastViewed, timestamp);
};
/**
* Methods to get/set the location from local storage
*/
+2
View File
@@ -44,6 +44,8 @@ export interface State {
lcr?: Lcr;
/** current selected service provider */
currentServiceProvider?: ServiceProvider;
/** count of unread alerts */
unreadAlerts: number;
}
/** Generic interface enforces type-safety with global dispatch */