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 ( + +
+
+
+
+
+ +   + +
+ { + setRealtimeList(!realtimeList); + }} + name="liveListCheckbox" + type="checkbox" + /> + + { + setRealtimeMap(!realtimeMap); + }} + name="liveMapCheckbox" + type="checkbox" + /> + + { + setListAll(!listAll); + }} + name="listAllCheckbox" + type="checkbox" + /> + + +
+ { + 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;