EarthRanger Web Client is the React single-page application that control-room operators, protected-area managers and conservation staff use to run EarthRanger, a real-time operational platform for protected areas. It puts sensor telemetry, field reports and partner data on one live map, so teams can see what is happening across a site and respond:
- Events: triage and resolve what rangers, sensors and integrations report, from a snare found to a geofence breach.
- Subjects and tracks: follow collared animals, rangers, vehicles and fixed sensors, with their tracks and heatmaps.
- Patrols: plan, run and review field patrols, leg by leg.
- Map layers: spatial features, analyzers, basemaps and ropeless fishing gear.
- Messaging and time slider: text subjects in the field, and replay the map at a past moment.
Every site (tenant) serves the client from its own domain, and the client talks only to that site's DAS backend, EarthRanger's server, which stores all data and configuration. Much of what the client shows is created by rangers in EarthRanger Mobile.
Tip
Use an AI agent to get to know the repository. AGENTS.md holds the full project context. Open the repository with an agent such as Claude Code, and ask it anything, from "what is an incident collection?" to "how do I set up the project locally?". See Agents.
- Git for version control
- Node.js 24 for JavaScript runtime
- Yarn 4 for package management
git clone git@github.com:PADAS/das-web-react.git
cd das-web-reactyarnVite loads the committed .env files in layers: .env always, then .env.development or .env.production depending on the mode. Keep your local overrides in a .env.development.local file, which is gitignored and takes precedence. At a minimum, point the client at a DAS backend:
echo "REACT_APP_DAS_HOST='https://root.dev.pamdas.org'" > .env.development.localThe available variables are:
REACT_APP_DAS_HOST: Origin of the DAS backend. Leave it empty in deployments, so each site talks to the origin it is served from.REACT_APP_DAS_API_URL,REACT_APP_DAS_API_V2_URL: Paths of the v1 and v2 APIs on that origin.REACT_APP_DAS_AUTH_TOKEN_URL: Path of the OAuth token endpoint for username and password sign-in.REACT_APP_MAPBOX_TOKEN: Mapbox access token (see License).REACT_APP_BASE_MAP_STYLES: Mapbox style URL of the default basemap.REACT_APP_ROUTE_PREFIX: Base path the app is mounted under (/).REACT_APP_DEFAULT_EVENT_FILTER_FROM_DAYS,REACT_APP_DEFAULT_PATROL_FILTER_FROM_DAYS: How many days back the event and patrol feeds reach by default.REACT_APP_GA4_TRACKING_ID: Google Analytics 4 measurement ID.PORT: Port of the development server (defaults to 9000).
Variables prefixed with REACT_APP_ are baked into the build, so changing them requires rebuilding the app.
The Auth0 tenant is not an environment variable: it is read at runtime from public/config.js, so one build can serve every environment. Without that file, the app uses the production tenant from src/config.js. To sign in to a development site that uses Auth0, copy the development template:
cp public/config.js.example public/config.jsSites without Auth0, such as root.dev.pamdas.org, sign in with a username and password and do not need it.
This project is a client-only single-page application with no server of its own: Vite serves it in development, and nginx serves the static build in deployments. Configuration lives in vite.config.mjs, which compiles every source .js file as JSX, imports SVGs as React components through vite-plugin-svgr, and adds the Osano cookie consent script to production builds. index.html at the root is the page that loads the app.
To start the Vite dev server:
yarn start- The server listens on the port defined in
.env(.env.developmentdefaults to 9000), and the app is available at http://localhost:9000. Vite provides HMR so changes apply automatically. - Sign in with a user of the DAS site in
REACT_APP_DAS_HOST.
Project context for AI coding agents is maintained in AGENTS.md. CLAUDE.md is a symlink to it so that Claude Code picks it up automatically, and most other agents read AGENTS.md on their own.
The client talks to the DAS REST API with Axios. Each duck calls Axios directly, building its URLs from API_URL and API_V2_URL in src/constants/, which join REACT_APP_DAS_HOST with the v1 or v2 API path. Ducks export their URL constants, so tests can mock the same endpoints.
For example, the event categories duck:
import axios from 'axios';
import { API_URL } from '../../constants';
export const EVENT_CATEGORIES_API_URL = `${API_URL}activity/events/categories`;
export const fetchEventCategories = () => async (dispatch) => {
const response = await axios.get(EVENT_CATEGORIES_API_URL);
// ...
};RequestConfigManager configures the shared Axios instance for every request: authorization, profiles, cancellation, session recovery.
withSocketConnection opens one Socket.IO connection to the site's /das namespace for the authenticated app, and shares it through SocketContext. Once connected, the client:
- Authorizes with the access token, and the active profile if there is one.
- Sends the current event and patrol filters, and sends them again whenever they change. The server tags each event and patrol it pushes with whether it still matches them.
- Pings the server every 30 seconds, and reconnects 5 seconds after any failure.
withSocketConnection/useRealTimeImplementation/config.js maps each server message to the actions it dispatches, such as new_patrol to socketCreatePatrol. The ducks' socket* action creators then add, update or drop the object in the store without refetching it. Events, patrols, subjects, tracks and messages stay live this way.
A component that needs a message only while it is mounted reads the socket from SocketContext, and unsubscribes on unmount. The app wraps socket.on to acknowledge each message to the server, so it returns the wrapped handler, and that is the one socket.off needs:
const socket = useContext(SocketContext);
useEffect(() => {
if (socket) {
const [, handlerRef] = socket.on('new_announcement', onAnnouncement);
return () => socket.off('new_announcement', handlerRef);
}
}, [onAnnouncement, socket]);Routes are declared with React Router in declarative mode, inside a BrowserRouter mounted under REACT_APP_ROUTE_PREFIX:
- Top level:
src/index.jsdeclares the routes inAPP_ROUTES, fromsrc/constants/routes.js./login,/eulaand/community/:value/*render outside the app shell; every other path renders the authenticated app (App.js), behind the access token and EULA guards. - Sidebar tabs:
SideBar/routes each tab by itsTAB_KEYSsegment (events,patrols,gear,layers,settings), so the URL decides which tab is open, and/closes it. - Nested routes: the Events and Patrols managers, in
SideBar/EventsManager/andSideBar/PatrolsManager/, nest their own routes, such as/events/:eventIdand/patrols/:patrolId/legs/:legId.
Navigate with the app's hooks/useNavigate rather than React Router's: it lets a form with unsaved changes prompt before the user leaves, and opens the sidebar. To read the current tab or item from a pathname, use getCurrentTabFromURL and getCurrentIdFromURL in utils/navigation.js.
Important
In deployments, nginx serves index.html only for the top-level paths listed in mt-nginx.conf. A new top-level route must be added there too, or loading it directly returns a 404.
React Bootstrap (Bootstrap 5) provides the base components, such as buttons, modals, popovers, dropdowns and tabs. Import each one from its own path rather than from the package root:
import Button from 'react-bootstrap/Button';
import Popover from 'react-bootstrap/Popover';Every component is a folder with its index.js, its index.test.js and, when it has styles, its styles.module.scss. Components live directly under src/, and move under their parent's folder once they belong to it, such as SideBar/PatrolsManager/LegForm/.
Modals open through a stack kept in Redux: dispatch addModal from ducks/modals.js with the component to show and its props, and ModalRenderer renders it inside a React Bootstrap Modal.
dispatch(addModal({ content: AddToPatrolModal, onAddToPatrol }));Shared hooks live in src/hooks/, such as useNavigate, the map hooks, and the use<Domain>Permissions hooks in hooks/usePermissions/, which read the permissions of the user or their active profile.
Redux holds the app's state. The root reducer in src/reducers/index.js splits the state in two:
state.data: data from the API, such as events, patrols and subjects.state.view: UI state, such as the open modals, map settings and user preferences.
All Redux logic for a domain lives in its duck under src/ducks/: the API URLs, action types, action creators and thunks, and the reducer.
// Actions
export const FETCH_EVENT_CATEGORIES_SUCCESS = 'FETCH_EVENT_CATEGORIES_SUCCESS';
// Action creators
export const fetchEventCategories = () => async (dispatch) => {
const response = await axios.get(EVENT_CATEGORIES_API_URL);
// ...
dispatch({ payload: eventCategories, type: FETCH_EVENT_CATEGORIES_SUCCESS });
};
// Reducer
export const INITIAL_STATE = {};
const eventCategoriesReducer = (state, action) => {
switch (action.type) {
case FETCH_EVENT_CATEGORIES_SUCCESS:
return action.payload;
default:
return state;
}
};
export default globallyResettableReducer(eventCategoriesReducer, INITIAL_STATE);Reference data every screen depends on, such as event types, event categories, patrol types and map quick links, is fetched once when the app shell mounts. Screens read it from the store rather than fetching it again.
Derived state belongs in a Reselect selector under src/selectors/<domain>/, not in a component. Selector names start with select, such as selectEventSchema.
Slices that survive a reload are wrapped in redux-persist's persistReducer in the root reducer:
- Most persist to
localStorage; large ones, such as spatial features and analyzers, persist to IndexedDB through localForage. - Some, such as the map position and what Map Layers hides, are restored only when the user chooses so in Settings → General.
Slices that must be cleared on sign-out are wrapped in globallyResettableReducer, which returns them to their initial state when signing out dispatches resetGlobalState.
What is not Redux data is shared through React context, such as the map in MapContext, the socket in SocketContext, navigation blocking in NavigationContext, and the analytics tracker in TrackerContext.
The map is rendered with Mapbox GL. EarthRangerMap/ creates its single instance: it sets REACT_APP_MAPBOX_TOKEN, starts from the REACT_APP_BASE_MAP_STYLES style, adds the access token to the tile requests made to the DAS API, and shares the instance through MapContext.
Map/index.js composes the map from layer components, such as SubjectsLayer, TracksLayer, EventsLayer and SpatialFeaturesLayer, each shown when its feature is enabled. A layer component renders nothing, it registers its Mapbox sources and layers.
Keep source and layer ids in SOURCE_IDS and LAYER_IDS, in src/constants/, so layers can reference each other, such as to draw one before another.
Each component keeps its styles in a co-located styles.module.scss, written in Sass and imported as a CSS module:
import * as styles from './styles.module.scss';
const MyComponent = ({ isActive }) => <div className={`${styles.container} ${isActive ? styles.active : ''}`}>
...
</div>;Shared styles live in src/common/styles/, and components pull them in with @use:
vars/: the color palette, fonts and control sizes._layout.scss: breakpoints, media queries and layout mixins._buttons.scss,_inputs.scss,_popovers.scssand others: mixins for common elements.
Base element styles, such as fonts and headings, are in src/index.scss.
Files in public/, such as favicon.ico, manifest.json, the translation files and config.js, are served as they are from the site's root.
Icons are SVG files in src/common/images/icons/, imported as React components through vite-plugin-svgr, so they can be styled and sized with className:
import { ReactComponent as CalendarIcon } from '../common/images/icons/calendar.svg';Append ?url to import a file's URL instead, such as to load it as a map image.
Other bundled images, such as the logo and sprites, live in src/common/images/, and notification sounds in src/common/sounds/.
Event type, patrol type and subject icons are not bundled: they come from the DAS server, and SvgIcon renders them, sanitizing the SVGs it fetches with DOMPurify.
The app is translated with i18next and react-i18next into English, Spanish, French, Nepali, Portuguese and Swahili. Translations load over HTTP, and are cached in the browser's localStorage.
Translation files live under public/locales/<language>/<namespace>.json, one file per UI namespace, such as patrols or top-bar. en-US is the source language. Within a namespace, each top-level component has its own key, named after it, and a subcomponent's key nests under its parent's.
Translations are read with the t function. How you get it depends on where the code runs:
- In components, with useTranslation, whose key prefix mirrors the component's position in the folder tree:
import { useTranslation } from 'react-i18next';
const SideBar = () => {
const { t } = useTranslation('components', { keyPrefix: 'sideBar' });
return <button aria-label={t('closeButtonLabel')} type="button">…</button>;
};- Outside components, such as in utilities, with getFixedT:
import i18next from 'i18next';
const t = i18next.getFixedT(null, 'utils', 'calcFriendlyDurationString');Create public/locales/<language>/<namespace>.json for every language, add the namespace to preloadNamespaces in src/i18n.js, and register its English file in src/i18nForTests.js, so tests render its real strings.
Browsers cache the translation files for a week, keyed on I18N_FILES_VERSION in src/i18n.js. When you change anything under public/locales/, bump that version above develop's, or returning users keep the old strings. CI enforces it, and you can check it locally:
yarn check-i18n-files-versionWe use Jest as our test runner, with React Testing Library for component testing, and MSW to mock HTTP requests. Each module keeps its tests in an index.test.js beside it.
Test setup uses:
package.json: the Jest configuration, underjest.src/setupTests.js: global setup and mocks, including a mocked socket connection.src/test-utils.jsx: a customrenderthat wraps components in the router, i18n and navigation providers.src/__test-helpers/: the mock Redux store, fixtures, and mocks such as the map's.src/i18nForTests.js: i18n configuration for tests, with the English strings.
Import render and screen from src/test-utils.jsx, and pass initialEntries when the component reads the URL. Supply Redux state with mockStore from src/__test-helpers/MockStore, and the map with createMapMock from src/__test-helpers/mocks through MapContext.Provider:
import { Provider } from 'react-redux';
import { createMapMock } from '../../../__test-helpers/mocks';
import { MapContext } from '../../../MapContext';
import { mockStore } from '../../../__test-helpers/MockStore';
import { render, screen } from '../../../test-utils';
import PatrolsFeed from './';
describe('SideBar - PatrolsManager - PatrolsFeed', () => {
const map = createMapMock();
let store;
beforeEach(() => {
store = { data: { /* ... */ }, view: { /* ... */ } };
});
const renderPatrolsFeed = () => render(
<Provider store={mockStore(store)}>
<MapContext.Provider value={map}>
<PatrolsFeed />
</MapContext.Provider>
</Provider>,
{ initialEntries: ['/patrols'] }
);
test('shows an empty state when no patrol matches the filters', async () => {
store.data.patrolsFeed = [];
renderPatrolsFeed();
expect(await screen.findByText('No patrols to display.')).toBeVisible();
});
});Build the MSW handlers from the duck's exported URL constants, and start and stop the server around the tests:
import { http, HttpResponse } from 'msw/http';
import { setupServer } from 'msw/node';
import { PATROLS_API_URL } from '../../../ducks/patrols';
const server = setupServer(
http.get(PATROLS_API_URL, () => HttpResponse.json({ data: { next: null, results: [] } }))
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Run all tests, or only some by path or pattern:
yarn test
yarn test <path-or-pattern>Note:
yarn testpinsTZ=UTC. Runningjestdirectly makes datetime tests fail on any machine outside UTC.
- ESLint: JavaScript linting, with the recommended rules of
@eslint/js,eslint-plugin-react,eslint-plugin-react-hooksand, for tests,eslint-plugin-jest. Configured ineslint.config.js. - Stylelint: SCSS linting, with
stylelint-config-standard,stylelint-config-css-modulesandstylelint-scss. Configured in.stylelintrc.json.
- Lint the JavaScript and the SCSS:
yarn lint
yarn stylelint- Lint and fix only the files you touched:
npx eslint --fix <files>To enhance your development experience in VS Code, consider installing these recommended extensions:
Compile for production:
yarn buildThis runs the Vite build into build/, then generates the service worker (build/sw.js). The output is a static site: any server can host it, as long as it falls back to index.html for the app's routes and serves /config.js (see Configure Auth0).
Dockerfile.mt builds the production image: it installs dependencies, builds the app with the env file passed as the ENV_FILE build argument (.env.development or .env.production), and serves the result with nginx, configured by mt-nginx.conf. Each environment provides its own config.js through a Kubernetes ConfigMap.
GitHub Workflows in .github/workflows/ test the code, build the image, and deploy it to Kubernetes through Argo CD:
- Pull requests run the tests and the i18n version check. Labeled
deploy, they also get their own environment athttps://<branch>.dev.pamdas.org, linked in a PR comment. Stale ones are cleaned up nightly. developdeploys to the development environment, and a failed run notifies the team on Slack.release-*branches deploy to stage and the legacy clusters, then, once approved, to each production region.
Contributions are welcome. Branch from develop and open a pull request against it, filling in the pull request template. See CONTRIBUTING.md for the full guidelines, and open an issue to report a bug or propose a feature.
This repository is open-sourced under the Apache 2.0 license. However, Mapbox GL, a required dependency, is closed-source. It is provided here in a "bring your own token" capacity: you may use and modify all EarthRanger-authored code herein, but will need to provide your own Mapbox access token, tied to your own Mapbox account.