diff --git a/.circleci/config.yml b/.circleci/config.yml
index d8649e5cba..cefa837fe2 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -22,7 +22,7 @@ jobs:
command: |
yarn --version
cd ${CIRCLE_WORKING_DIRECTORY}/frontend
- yarn install
+ yarn install --network-concurrency 1 # lodash which has prepare script fails. Hotfix: https://github.com/yarnpkg/yarn/issues/6312
- save_cache:
key: yarn-deps-{{ checksum "frontend/yarn.lock" }}
paths:
diff --git a/.github/workflows/build_and_deploy_backend.yml b/.github/workflows/build_and_deploy_backend.yml
new file mode 100644
index 0000000000..4639f81747
--- /dev/null
+++ b/.github/workflows/build_and_deploy_backend.yml
@@ -0,0 +1,32 @@
+name: Build and Deploy Tasking Manager Backend
+
+on:
+ push:
+ branches:
+ - deployment/naxa
+ paths:
+ - "backend/**"
+ workflow_dispatch:
+
+jobs:
+ deploy:
+ name: Deploy to Server
+ runs-on: [ubuntu-latest]
+ steps:
+ - name: Clone repository
+ uses: actions/checkout@v3
+
+ - name: Recreate Services
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ vars.SERVER_IP }}
+ username: ${{ vars.SERVER_USERNAME }}
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ command_timeout: 20m
+ script: |
+ echo '==In Server=='
+ echo '==Building Backend=='
+ cd /home/ubuntu/Projects/tasking-manager
+ git pull naxa deployment/naxa
+ docker compose build --no-cache backend
+ docker compose up -d --force-recreate
diff --git a/.github/workflows/build_frontend_custom.yml b/.github/workflows/build_frontend_custom.yml
new file mode 100644
index 0000000000..4baf5416ea
--- /dev/null
+++ b/.github/workflows/build_frontend_custom.yml
@@ -0,0 +1,34 @@
+name: TM NAXA Custom Branch CICD
+
+on:
+ push:
+ tags:
+ - force-deploy*
+ workflow_dispatch:
+
+jobs:
+ build-static-for-branch:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Clone repository
+ uses: actions/checkout@v2
+
+ - name: Use Node.js 16
+ uses: actions/setup-node@v1
+ with:
+ node-version: 16.x
+
+ - name: Install dependencies
+ run: cd frontend && yarn install --network-concurrency 1
+
+ - name: Generate build
+ run: cd frontend && GENERATE_SOURCEMAP=false && yarn build
+
+ - name: copy file via ssh password
+ uses: appleboy/scp-action@v0.1.7
+ with:
+ host: ${{ vars.SERVER_IP }}
+ username: ${{ vars.SERVER_USERNAME }}
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ source: "./frontend/build/"
+ target: /home/ubuntu/Projects/tasking-manager/frontend/build/
diff --git a/example.env b/example.env
index 37f9e59302..f87204248f 100644
--- a/example.env
+++ b/example.env
@@ -205,6 +205,10 @@ TM_DEFAULT_LOCALE=en
# Sentry.io DSN Config (optional)
# TM_SENTRY_BACKEND_DSN=https://foo.ingest.sentry.io/1234567
# TM_SENTRY_FRONTEND_DSN=https://bar.ingest.sentry.io/8901234
+#
+
+# Underpass API URL (for project live monitoring feature)
+UNDERPASS_URL=https://underpass.hotosm.org
#EXPORT TOOL Integration with 0(Disable) and 1(Enable) and S3 URL for Export Tool
diff --git a/frontend/package.json b/frontend/package.json
index 6aff4dab72..486a8ba48a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,15 +10,16 @@
"@formatjs/macro": "^0.2.8",
"@hotosm/id": "^2.21.1",
"@hotosm/iso-countries-languages": "^1.1.2",
+ "@hotosm/underpass-ui": "https://github.com/hotosm/underpass-ui.git",
"@mapbox/mapbox-gl-draw": "^1.4.1",
"@mapbox/mapbox-gl-geocoder": "^5.0.1",
"@mapbox/mapbox-gl-language": "^0.10.1",
"@placemarkio/geo-viewport": "^1.0.1",
"@rapideditor/rapid": "^2.1.1",
"@sentry/react": "^7.60.1",
- "@tmcw/togeojson": "^4.7.0",
"@tanstack/react-query": "^4.29.7",
"@tanstack/react-query-devtools": "^4.29.7",
+ "@tmcw/togeojson": "^4.7.0",
"@turf/area": "^6.5.0",
"@turf/bbox": "^6.5.0",
"@turf/bbox-polygon": "^6.5.0",
diff --git a/frontend/src/api/projects.js b/frontend/src/api/projects.js
index f9d46564d3..663ca61fda 100644
--- a/frontend/src/api/projects.js
+++ b/frontend/src/api/projects.js
@@ -1,9 +1,11 @@
+import axios from 'axios';
import { subMonths, format } from 'date-fns';
import { useQuery } from '@tanstack/react-query';
import { useSelector } from 'react-redux';
import { remapParamsToAPI } from '../utils/remapParamsToAPI';
import api from './apiClient';
+import { UNDERPASS_URL } from '../config';
export const useProjectsQuery = (fullProjectsQuery, action) => {
const token = useSelector((state) => state.auth.token);
@@ -187,6 +189,18 @@ export const submitValidationTask = (projectId, payload, token, locale) => {
);
};
+export const useAvailableCountriesQuery = () => {
+ const fetchGeojsonData = () => {
+ return axios.get(`${UNDERPASS_URL}/availability`);
+ };
+
+ return useQuery({
+ queryKey: ['priority-geojson'],
+ queryFn: fetchGeojsonData,
+ select: (res) => res.data,
+ });
+};
+
const backendToQueryConversion = {
difficulty: 'difficulty',
campaign: 'campaign',
diff --git a/frontend/src/components/footer/index.js b/frontend/src/components/footer/index.js
index b38155e802..e0da9ddb06 100644
--- a/frontend/src/components/footer/index.js
+++ b/frontend/src/components/footer/index.js
@@ -38,6 +38,7 @@ export function Footer() {
'projects/:id/tasks',
'projects/:id/map',
'projects/:id/validate',
+ 'projects/:id/live',
'manage/organisations/new/',
'manage/teams/new',
'manage/campaigns/new',
diff --git a/frontend/src/components/projectDetail/downloadOsmData.js b/frontend/src/components/projectDetail/downloadOsmData.js
index db895e6d13..bb411cf9c8 100644
--- a/frontend/src/components/projectDetail/downloadOsmData.js
+++ b/frontend/src/components/projectDetail/downloadOsmData.js
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
-import { RoadIcon, HomeIcon, WavesIcon, TaskIcon, DownloadIcon } from '../svgIcons';
+import { RoadIcon, HomeIcon, WavesIcon, TaskIcon, DownloadIcon, InfoIcon } from '../svgIcons';
import FileFormatCard from './fileFormatCard';
import Popup from 'reactjs-popup';
import { EXPORT_TOOL_S3_URL } from '../../config';
diff --git a/frontend/src/components/projectDetail/index.js b/frontend/src/components/projectDetail/index.js
index 183e7f0ad5..1f2b0c9630 100644
--- a/frontend/src/components/projectDetail/index.js
+++ b/frontend/src/components/projectDetail/index.js
@@ -21,8 +21,13 @@ import { PermissionBox } from './permissionBox';
import { CustomButton } from '../button';
import { ProjectInfoPanel } from './infoPanel';
import { OSMChaButton } from './osmchaButton';
+import { LiveViewButton } from './liveViewButton';
import { useSetProjectPageTitleTag } from '../../hooks/UseMetaTags';
-import { useProjectContributionsQuery, useProjectTimelineQuery } from '../../api/projects';
+import {
+ useProjectContributionsQuery,
+ useProjectTimelineQuery,
+ useAvailableCountriesQuery,
+} from '../../api/projects';
import { Alert } from '../alert';
import './styles.scss';
@@ -153,6 +158,16 @@ export const ProjectDetail = (props) => {
);
+ const { data } = useAvailableCountriesQuery();
+
+ // check if the project has live monitoring feature enabled
+ // based on the country list provided by available.json
+ const hasLiveMonitoringFeature = !data
+ ? false
+ : props.project.countryTag.some((country) =>
+ data.countries.some((item) => country.toLowerCase() === item.toLowerCase()),
+ );
+
return (
@@ -346,6 +361,18 @@ export const ProjectDetail = (props) => {
project={props.project}
className="bg-white blue-dark ba b--grey-light pa3"
/>
+
+ {/*
+ show live view button only for published projects &
+ when the project has live monitoring feature
+ */}
+ {props.project.status === 'PUBLISHED' && hasLiveMonitoringFeature && (
+
+ )}
+
(
+
+ {
+
+ {compact ? (
+
+ ) : (
+
+ )}
+
+ }
+
+);
diff --git a/frontend/src/components/projectDetail/messages.js b/frontend/src/components/projectDetail/messages.js
index 76e452b827..cd457852c4 100644
--- a/frontend/src/components/projectDetail/messages.js
+++ b/frontend/src/components/projectDetail/messages.js
@@ -240,6 +240,14 @@ export default defineMessages({
id: 'project.detail.sections.contributions.osmcha',
defaultMessage: 'Changesets in OSMCha',
},
+ live: {
+ id: 'project.detail.sections.contributions.live',
+ defaultMessage: 'Live',
+ },
+ liveMonitoring: {
+ id: 'project.detail.sections.contributions.liveMonitoring',
+ defaultMessage: 'Live monitoring',
+ },
changesets: {
id: 'project.detail.sections.contributions.changesets',
defaultMessage: 'Changesets',
diff --git a/frontend/src/components/projectStats/contributorsStats.js b/frontend/src/components/projectStats/contributorsStats.js
index f67a306f2d..5bf5136674 100644
--- a/frontend/src/components/projectStats/contributorsStats.js
+++ b/frontend/src/components/projectStats/contributorsStats.js
@@ -19,6 +19,8 @@ import { formatChartData, formatTooltip } from '../../utils/formatChartJSData';
import { useContributorStats } from '../../hooks/UseContributorStats';
import { StatsCardContent } from '../statsCard';
+ChartJS.register(ArcElement, BarElement, CategoryScale, LinearScale);
+
export default function ContributorsStats({ contributors }) {
ChartJS.register(BarElement, CategoryScale, Legend, LinearScale, Title, Tooltip, ArcElement);
const intl = useIntl();
diff --git a/frontend/src/components/teamsAndOrgs/tasksStatsChart.js b/frontend/src/components/teamsAndOrgs/tasksStatsChart.js
index ca69e1584b..2e467e43f3 100644
--- a/frontend/src/components/teamsAndOrgs/tasksStatsChart.js
+++ b/frontend/src/components/teamsAndOrgs/tasksStatsChart.js
@@ -41,15 +41,11 @@ const TasksStatsChart = ({ stats }) => {
},
scales: {
y: {
- stacked: true,
ticks: {
beginAtZero: true,
},
},
- x: {
- stacked: true,
- ...xAxisTimeSeries(unit),
- },
+ x: { ...xAxisTimeSeries(unit) },
},
};
return (
diff --git a/frontend/src/config/index.js b/frontend/src/config/index.js
index 941c8de1d0..8e27d983b0 100644
--- a/frontend/src/config/index.js
+++ b/frontend/src/config/index.js
@@ -6,6 +6,10 @@ export const API_URL = process.env.REACT_APP_API_URL
export const OHSOME_STATS_BASE_URL =
process.env.REACT_APP_OHSOME_STATS_BASE_URL || 'https://stats.now.ohsome.org/api';
+export const OHSOME_STATS_TOKEN = process.env.REACT_APP_OHSOME_STATS_TOKEN || '';
+export const UNDERPASS_URL = process.env.REACT_APP_UNDERPASS_URL || 'https://underpass.hotosm.org';
+
+
// APPLICATION SETTINGS
export const DEFAULT_LOCALE = process.env.REACT_APP_DEFAULT_LOCALE || 'en';
export const ENVIRONMENT = process.env.REACT_APP_ENVIRONMENT || '';
diff --git a/frontend/src/routes.js b/frontend/src/routes.js
index 4a5574ed0e..d963246247 100644
--- a/frontend/src/routes.js
+++ b/frontend/src/routes.js
@@ -90,6 +90,16 @@ export const router = createBrowserRouter(
}}
ErrorBoundary={FallbackComponent}
/>
+ {
+ const { ProjectLiveMonitoring } = await import(
+ './views/projectLiveMonitoring' /* webpackChunkName: "projectLiveMonitoring" */
+ );
+ return { Component: ProjectLiveMonitoring };
+ }}
+ ErrorBoundary={FallbackComponent}
+ />
{
diff --git a/frontend/src/views/projectLiveMonitoring.css b/frontend/src/views/projectLiveMonitoring.css
new file mode 100644
index 0000000000..d5d8915313
--- /dev/null
+++ b/frontend/src/views/projectLiveMonitoring.css
@@ -0,0 +1,37 @@
+@import '@hotosm/underpass-ui/dist/index.css';
+
+.maplibregl-map {
+ height: calc(100vh - 6.05rem);
+}
+
+.top {
+ position: absolute;
+ top: 0.625rem;
+ left: 0.625rem;
+ z-index: 999;
+}
+
+svg.pl2 {
+ display: inherit;
+ height: 22px !important;
+ width: 19px !important;
+ margin-left: 0px;
+}
+
+.react-select__control {
+ border-radius: 0.25rem !important;
+ min-height: 36px !important;
+ width: 15.8rem;
+}
+
+.react-select__menu {
+ width: 15.8rem !important;
+}
+
+.title-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
diff --git a/frontend/src/views/projectLiveMonitoring.js b/frontend/src/views/projectLiveMonitoring.js
new file mode 100644
index 0000000000..188ef00a4e
--- /dev/null
+++ b/frontend/src/views/projectLiveMonitoring.js
@@ -0,0 +1,401 @@
+import React, { useState, useRef, useEffect } from 'react';
+import ReactPlaceholder from 'react-placeholder';
+import Select from 'react-select';
+import centroid from '@turf/centroid';
+import {
+ UnderpassFeatureList,
+ UnderpassMap,
+ HOTTheme,
+ UnderpassFeatureStats,
+ UnderpassValidationStats,
+} from '@hotosm/underpass-ui';
+import { Link } from 'react-router-dom';
+import { useDispatch } from 'react-redux';
+
+import { ProjectVisibilityBox } from '../components/projectDetail/visibilityBox';
+import { ProjectStatusBox } from '../components/projectDetail/statusBox';
+import { useSetTitleTag } from '../hooks/UseMetaTags';
+import { useParams } from 'react-router-dom';
+import { useFetch } from '../hooks/UseFetch';
+import './projectLiveMonitoring.css';
+import { MAPBOX_TOKEN, UNDERPASS_URL } from '../config';
+
+const availableImageryOptions = [
+ { label: 'OSM', value: 'osm' },
+ { label: 'Bing', value: 'Bing' },
+ { label: 'Mapbox Satellite', value: 'Mapbox' },
+ { label: 'ESRI World Imagery', value: 'EsriWorldImagery' },
+];
+
+const availableImageryValues = availableImageryOptions.map((item) => item.value);
+
+const config = {
+ API_URL: UNDERPASS_URL,
+ MAPBOX_TOKEN: MAPBOX_TOKEN,
+ // set default sources of Tasking Manager
+ sources: {
+ osm: {
+ type: 'raster',
+ tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'],
+ tileSize: 256,
+ attribution: '© OpenStreetMap Contributors',
+ maxzoom: 19,
+ },
+ Bing: {
+ type: 'raster',
+ tiles: ['https://ecn.t3.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1'],
+ tileSize: 256,
+ attribution: '© OpenStreetMap Contributors',
+ maxzoom: 18,
+ },
+ Mapbox: {
+ type: 'raster',
+ tiles: [
+ `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/{z}/{x}/{y}?access_token=${MAPBOX_TOKEN}`,
+ ],
+ tileSize: 512,
+ attribution: '© OpenStreetMap Contributors © Mapbox',
+ maxzoom: 19,
+ },
+ EsriWorldImagery: {
+ type: 'raster',
+ tiles: [
+ 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+ ],
+ tileSize: 256,
+ attribution: '© OpenStreetMap Contributors © ESRI',
+ maxzoom: 18,
+ },
+ },
+};
+
+const statusList = {
+ ALL: '',
+ UNSQUARED: 'badgeom',
+ OVERLAPPING: 'overlapping',
+ BADVALUE: 'badvalue',
+};
+
+const mappingTypesTags = {
+ ROADS: 'highway',
+ BUILDINGS: 'building',
+ WATERWAYS: 'waterway',
+};
+
+const mappingTypesFeatureTypes = {
+ ROADS: 'line',
+ BUILDINGS: 'polygon',
+ WATERWAYS: 'line',
+};
+
+export function ProjectLiveMonitoring() {
+ const { id } = useParams();
+ const dispatch = useDispatch();
+ const [coords, setCoords] = useState([0, 0]);
+ const [activeFeature, setActiveFeature] = useState(null);
+ const [tags, setTags] = useState('building');
+ const [featureType, setFeatureType] = useState('polygon');
+ const [mapSource, setMapSource] = useState('osm');
+ const [imageryOptions, setImageryOptions] = useState(availableImageryOptions);
+ const [mapConfig, setMapConfig] = useState(config);
+ const [realtimeList, setRealtimeList] = useState(false);
+ const [realtimeMap, setRealtimeMap] = useState(false);
+ const [listAll, setListAll] = useState(false);
+ // eslint-disable-next-line
+ const [status, setStatus] = useState(statusList.UNSQUARED);
+ // eslint-disable-next-line
+ const [area, setArea] = useState(null);
+ const tagsInputRef = useRef('');
+
+ useSetTitleTag(`Project #${id} Live Monitoring`);
+ const [error, loading, data] = useFetch(`projects/${id}/`, id);
+
+ const [areaOfInterest, setAreaOfInterest] = useState(null);
+ const [project, setProject] = useState(null);
+
+ useEffect(() => {
+ if (!Object.keys(data).length) return;
+ setProject(data);
+ // add custom to config sources if the project has custom imagery
+ const hasCustomImagery = data.imagery?.includes('http');
+ if (hasCustomImagery) {
+ setMapConfig((prev) => ({
+ ...prev,
+ sources: {
+ ...prev.sources,
+ custom: {
+ type: 'raster',
+ tiles: [data.imagery],
+ tileSize: 256,
+ attribution: 'custom',
+ maxzoom: 19,
+ },
+ },
+ }));
+ setImageryOptions((prev) => [...prev, { label: 'Custom', value: 'custom' }]);
+ }
+ // set mapSource after data fetch
+ const mapSourceValue = hasCustomImagery
+ ? 'custom'
+ : availableImageryValues.includes(data.imagery)
+ ? data.imagery
+ : 'osm';
+ setMapSource(mapSourceValue);
+ }, [data]);
+
+ useEffect(() => {
+ if (project && project.aoiBBOX && project.areaOfInterest) {
+ const bbox = [
+ [project.aoiBBOX[0], project.aoiBBOX[1]],
+ [project.aoiBBOX[0], project.aoiBBOX[3]],
+ [project.aoiBBOX[2], project.aoiBBOX[3]],
+ [project.aoiBBOX[2], project.aoiBBOX[1]],
+ [project.aoiBBOX[0], project.aoiBBOX[1]],
+ ];
+ setCoords(
+ centroid({
+ type: 'MultiPolygon',
+ coordinates: [[bbox]],
+ }).geometry.coordinates,
+ );
+ setAreaOfInterest(
+ [
+ bbox[0].join(' '),
+ bbox[1].join(' '),
+ bbox[2].join(' '),
+ bbox[3].join(' '),
+ bbox[4].join(' '),
+ ].join(','),
+ );
+ setTags(mappingTypesTags[project.mappingTypes] || 'building');
+ setFeatureType(mappingTypesFeatureTypes[project.mappingTypes] || 'polygon');
+ }
+ }, [project]);
+
+ // set organization bar visibility to false
+ useEffect(() => {
+ dispatch({ type: 'SET_VISIBILITY', isVisible: false });
+ return () => {
+ dispatch({ type: 'SET_VISIBILITY', isVisible: true });
+ };
+ }, [dispatch]);
+
+ const hottheme = HOTTheme();
+
+ const defaultMapStyle = {
+ waysLine: {
+ ...hottheme.map.waysLine,
+ 'line-opacity': 0.8,
+ },
+ waysFill: {
+ ...hottheme.map.waysFill,
+ 'fill-opacity': ['match', ['get', 'type'], 'LineString', 0, 0.3],
+ },
+ nodesSymbol: {
+ ...hottheme.map.nodesSymbol,
+ 'icon-opacity': ['match', ['get', 'type'], 'Point', 0.8, 0],
+ },
+ };
+
+ // eslint-disable-next-line
+ const [demoTheme, setDemoTheme] = useState({
+ map: defaultMapStyle,
+ });
+
+ const handleFilterClick = (e) => {
+ e.preventDefault();
+ setTags(tagsInputRef.current.value);
+ return false;
+ };
+
+ const handleMapSourceSelect = (selectedItem) => {
+ setMapSource(selectedItem.value);
+ };
+
+ const handleMapMove = ({ bbox }) => {
+ setArea(bbox);
+ };
+ const handleMapLoad = ({ bbox }) => {
+ setArea(bbox);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {project && (
+ <>
+
+
+
+
+ #{project.projectId}
+
+
+ {project.organisationName ? (
+ | {project.organisationName}
+ ) : null}
+
+
+
+
+
+ {project.projectInfo && project.projectInfo.name}
+
+ {project.private && (
+
+ )}
+ {['DRAFT', 'ARCHIVED'].includes(project.status) && (
+
+ )}
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
{
+ setCoords([feature.lat, feature.lon]);
+ const tags = JSON.stringify(feature.tags);
+ const status = feature.status;
+ setActiveFeature({ properties: { tags, status }, ...feature });
+ }}
+ realtime={realtimeList}
+ config={config}
+ status={listAll ? '' : status}
+ orderBy="created_at"
+ onFetchFirstTime={(mostRecentFeature) => {
+ if (mostRecentFeature) {
+ setCoords([mostRecentFeature.lat, mostRecentFeature.lon]);
+ }
+ }}
+ />
+
+
+
+
+ );
+}
+
+export default ProjectLiveMonitoring;