diff --git a/.env b/.env index 97887ac..842cf89 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ PUBLIC_URL=. -PORT=3002 \ No newline at end of file +PORT=3002 diff --git a/README.md b/README.md index cde142d..ccba729 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,108 @@ Visualizations of coverage and performance analysis for Community Cellular Netwo Now hosted on https://coverage.seattlecommunitynetwork.org/ -# Testing & Deployment + +## Initial Setup +To install this service, the fist time, you will need to: + +1. Required tools and versions: + 1. Install `node` and `npm` according to the directions at https://nodejs.org/en/download/package-manager + 2. Install `pm2` using: `npm install pm2 -g` (as per https://www.npmjs.com/package/pm2#installing-pm2) +2. Clone the service: `https://github.com/Local-Connectivity-Lab/ccn-coverage-vis` +2. Configure: + 1. `cd cd ccn-coverage-vis` + 1. Edit `src/utils/config.ts` and set the correct URL for your API host (if you're testing or you're deploying to a new URL). +4. Deploy as below. +5. When starting the ccn-coverage-vis service the first time, use: + ``` + pm2 start --name "Vis Server" npm -- run start + ``` + This will register ccn-coverage-vis with [PM2](https://pm2.keymetrics.io/docs/usage/quick-start/). + + +## Deploying +Once the service has been setup (as above), it can be deployed using the following process: +1. Login to the coverage-host +2. Pull the lastest version from github +3. Restart the service + +The shell commands are: +``` +ssh coverage-host +cd ccn-coverage-vis +git pull +npm install +npm run build +pm2 restart Vis Server +``` + +## Troubleshooting & Recovery +When a problem occurs, there are several checks to determine where the failure is: +1. Check HTTP errors in the browser +1. Login to the coverage-host +2. Confirm ccn-coverage-vis is operating as expected +3. Confirm nginx is operating as expected + +### Checking HTTP errors in the browser +First, open your browser and go to: https://coverage.seattlecommunitynetwork.org/ + +Is it working? + +If not, open up the browser **Web Developer Tools**, usually under the menu Tools > Developer Tools > Web Developer Tools. + +With this panel open at the bottom of the screen select the **Network** tab and refresh the browser page. + +Look in the first column, Status: +* `200`: OK, everything is good. +* `502`: Error with the backend services (behind nginx) +* `500` errors: problem with nxginx. Look in `/var/log/nginx/error.log` for details. +* `400` errors: problem with the service. Check the service logs and nginx logs. +* Timeout or unreachable error: Something is broken in the network between your web browser and the coverage-vis host. + + +### Checking ccn-coverage-vis with pm2 +Next, confirm ccn-coverage-vis is operating as expected. To do this, you will need to be able to log into the server hosting the coverage service. + +Use `pm2 list` to confirm the "Vis Server" is **online** +``` +ssh coverage-host +pm2 list +``` +![Online services under PM2](pm2-running-services.png "Online services under PM2") + +If the "Vis Server" is not online as expected, restart it with: +``` +pm2 restart Vis Server +``` + + +### Checking nginx +If there appear problems with nginx, then check that the + +Check service operation: +``` +systemctl status nginx +``` + +Check nginx logs: +``` +sudo tail /var/log/nginx/error.log +``` + +Sources of errors might include nginx configuration in `/etc/nginx/conf.d/01-ccn-coverage.conf` + +If you need to restart nginx, use: +``` +sudo systemctl restart nginx +``` + +### Clean Recovery +If nothing else works, the last option is a clean reinstall of the service. The process is: +* Remove the `ccn-coverage-vis` directory. +* Re-install as per **Initial Setup**. + + +## Testing Changes to the main branch are automically built and deployed to: https://seattlecommunitynetwork.org/ccn-coverage-vis/ diff --git a/package.json b/package.json index 79723c8..ee25fd8 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,8 @@ "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", - "format": "prettier --write --config ./prettierrc.json 'src/**/*.{ts,tsx}'", - "format-check": "prettier --check --config ./prettierrc.json 'src/**/*.{ts,tsx}'" + "format": "prettier --write --config ./prettierrc.json src/**/*.{ts,tsx}", + "format-check": "prettier --check --config ./prettierrc.json src/**/*.{ts,tsx}" }, "eslintConfig": { "extends": [ diff --git a/pm2-running-services.png b/pm2-running-services.png new file mode 100644 index 0000000..7ff8f50 Binary files /dev/null and b/pm2-running-services.png differ diff --git a/src/vis/MeasurementMap.tsx b/src/vis/MeasurementMap.tsx index 40866f1..ed821a4 100644 --- a/src/vis/MeasurementMap.tsx +++ b/src/vis/MeasurementMap.tsx @@ -1,22 +1,22 @@ -import React, { useEffect, useState } from 'react'; -import { MapType } from './MapSelectionRadio'; -import { API_URL } from '../utils/config'; -import { SCRAPER_URL } from '../utils/config'; -import * as L from 'leaflet'; -import * as d3 from 'd3'; -import * as parser from 'parse-address'; +import React, { useEffect, useState } from "react"; +import { MapType } from "./MapSelectionRadio"; +import { API_URL } from "../utils/config"; +import { SCRAPER_URL } from "../utils/config"; +import * as L from "leaflet"; +import * as d3 from "d3"; +import * as parser from "parse-address"; import { siteMarker, siteSmallMarker, isSiteArray, -} from '../leaflet-component/site-marker'; -import getBounds from '../utils/get-bounds'; -import MapLegend from './MapLegend'; -import fetchToJson from '../utils/fetch-to-json'; -import Loading from '../Loading'; -import axios from 'axios'; -import { GeoSearchControl, OpenStreetMapProvider } from 'leaflet-geosearch'; -import 'leaflet-geosearch/dist/geosearch.css'; +} from "../leaflet-component/site-marker"; +import getBounds from "../utils/get-bounds"; +import MapLegend from "./MapLegend"; +import fetchToJson from "../utils/fetch-to-json"; +import Loading from "../Loading"; +import axios from "axios"; +import { GeoSearchControl, OpenStreetMapProvider } from "leaflet-geosearch"; +import "leaflet-geosearch/dist/geosearch.css"; // Updated with details from: https://stadiamaps.com/stamen/onboarding/migrate/ const ATTRIBUTION = @@ -26,7 +26,7 @@ const ATTRIBUTION = '© OpenStreetMap contributors'; const URL = `https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}${ - devicePixelRatio > 1 ? '@2x' : '' + devicePixelRatio > 1 ? "@2x" : "" }.png`; const BIN_SIZE_SHIFT = 0; @@ -34,7 +34,7 @@ const DEFAULT_ZOOM = 10; const LEGEND_WIDTH = 25; function cts(p: Cell): string { - return p.x + ',' + p.y; + return p.x + "," + p.y; } // print results of user search event to console @@ -43,7 +43,7 @@ function searchEventHandler(result: any): void { console.log(result); var addressLabel = result.location.label; console.log(addressLabel); - // var parser = require("parse-address"); + // var parser = require("parse-address"); var parsedAddr = parser.parseLocation(addressLabel); console.log("hello 1"); console.log(parsedAddr); @@ -65,48 +65,81 @@ function searchEventHandler(result: any): void { var postcode = result.location.raw.address.postcode; var state = result.location.raw.address.state; - var url = SCRAPER_URL // server "http://127.0.0.1:8000/" - + "?state=" + state - + "&cityname=" + city - + "&primary=" + parsedAddr.number - + "&street_number=" + parsedAddr.street - + "&st=" + parsedAddr.type - + "&post_direction=" + post_direction - + "&zip_5=" + postcode; - //+ "&zip_9=" + "3207"; + var url = + SCRAPER_URL + // server "http://127.0.0.1:8000/" + "?state=" + + state + + "&cityname=" + + city + + "&primary=" + + parsedAddr.number + + "&street_number=" + + parsedAddr.street + + "&st=" + + parsedAddr.type + + "&post_direction=" + + post_direction + + "&zip_5=" + + postcode; + //+ "&zip_9=" + "3207"; console.log(url); - const xhr = new XMLHttpRequest(); //xhr.open('GET', "http://127.0.0.1:8000/?state=va&cityname=arlington&primary=3109&street_number=9th&st=St&post_direction=N&zip_5=22201&zip_9=2024"); - xhr.open('GET', url); - xhr.onload = function() { - console.log("200 check"); - console.log(xhr.status); - if (xhr.status === 200) { - //result.marker.setPopupContent(xhr.responseText); - result.marker.setPopupContent(organizePopup(xhr.responseText, postcode, lon, lat)); - } + xhr.open("GET", url); + xhr.onload = function () { + console.log("200 check"); + console.log(xhr.status); + if (xhr.status === 200) { + //result.marker.setPopupContent(xhr.responseText); + result.marker.setPopupContent( + organizePopup(xhr.responseText, postcode, lon, lat) + ); + } }; xhr.send(); } - - // organize popup content. called from searchEvenHandler -function organizePopup(apiText: any, postcode: any, lon: any, lat: any): string{ +function organizePopup( + apiText: any, + postcode: any, + lon: any, + lat: any +): string { let dict = JSON.parse(apiText); console.log(dict); - if(Object.keys(dict).length == 0) { - return "No prices could be found." + "Visit " + "FCC National Broadband Map." + " to see providers in your area, and " - + "allconnect.com" + " to see provider rates in your area."; + if (Object.keys(dict).length === 0) { + return ( + "No prices could be found." + + "Visit " + + "FCC National Broadband Map." + + " to see providers in your area, and " + + "allconnect.com" + + " to see provider rates in your area." + ); } - let returnString = "" - + "" + "" - + "" - + "" - + "" - + "" + ""; + let returnString = + "
" + "Provider" + "" + "Speeds" + "" + "Rate" + "
" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; if ("message" in dict) { return dict["message"]; @@ -116,26 +149,43 @@ function organizePopup(apiText: any, postcode: any, lon: any, lat: any): string{ // for loop that goes over each key in dictionary for (let key in dict) { - /*returnString = returnString + "" + key + "" + ": Available speeds up to " + dict2[key]["Available speeds"] + /*returnString = returnString + "" + key + "" + ": Available speeds up to " + dict2[key]["Available speeds"] + ", Starting at " + dict2[key]["Starting at"] + "
" + "
";*/ let keyName = key; if (keyName.endsWith(" Internet")) { keyName = keyName.substring(0, keyName.length - 9); console.log(keyName); } - returnString += "" - + "" - + "" - + "" - + ""; - + returnString += + "" + + "" + + "" + + "" + + ""; } - + returnString = returnString + "
" + + "Provider" + + "" + + "Speeds" + + "" + + "Rate" + + "
" + keyName + "" + dict[key]["Available speeds"] + "" + dict[key]["Starting at"] + "
" + + keyName + + "" + + dict[key]["Available speeds"] + + "" + + dict[key]["Starting at"] + + "
"; - returnString = returnString + "

"+ "Disclaimer: The table above shows a general estimate of the rates and providers in your area, using information from these sources: " - + "
" + "allconnect.com" - + "
" + "FCC National Broadband Map." - + "

"; + returnString = + returnString + + "

" + + "Disclaimer: The table above shows a general estimate of the rates and providers in your area, using information from these sources: " + + "
" + + "allconnect.com" + + "
" + + "FCC National Broadband Map." + + "

"; return returnString; } @@ -146,10 +196,10 @@ function resultFormat(result: any): string { } export const UNITS = { - dbm: 'dBm', - ping: 'ms', - download_speed: 'Mbps', - upload_speed: 'Mbps', + dbm: "dBm", + ping: "ms", + download_speed: "Mbps", + upload_speed: "Mbps", } as const; export const MULTIPLIERS = { @@ -160,10 +210,10 @@ export const MULTIPLIERS = { } as const; export const MAP_TYPE_CONVERT = { - dbm: 'Signal Strength', - ping: 'Ping', - download_speed: 'Download Speed', - upload_speed: 'Upload Speed', + dbm: "Signal Strength", + ping: "Ping", + download_speed: "Download Speed", + upload_speed: "Upload Speed", } as const; interface MapProps { @@ -224,8 +274,8 @@ const MeasurementMap = ({ useEffect(() => { (async () => { - const dataRange = await fetchToJson(API_URL + '/api/dataRange'); - const _map = L.map('map-id').setView(dataRange.center, DEFAULT_ZOOM); + const dataRange = await fetchToJson(API_URL + "/api/dataRange"); + const _map = L.map("map-id").setView(dataRange.center, DEFAULT_ZOOM); const _bounds = getBounds({ ...dataRange, map: _map, width, height }); L.tileLayer(URL, { @@ -248,27 +298,25 @@ const MeasurementMap = ({ provider: new OpenStreetMapProvider({ params: { addressdetails: 1, - } - + countrycodes: "us", // limit to USA + }, }), - style: 'bar', // optional: bar|button - default button + style: "bar", // optional: bar|button - default button showPopup: true, marker: { icon: new L.Icon.Default(), - draggable:false, + draggable: false, }, popupFormat: resultFormat, - //showMarker: false, + //showMarker: false, }); _map.addControl(search); - _map.on('geosearch/showlocation', searchEventHandler); + _map.on("geosearch/showlocation", searchEventHandler); /*var marker = L.marker(search.location) .bindPopup("hello") .addTo(_map);*/ - - })(); }, [width, height]); @@ -279,11 +327,11 @@ const MeasurementMap = ({ } const _siteSummary = await fetchToJson( API_URL + - '/api/sitesSummary?' + + "/api/sitesSummary?" + new URLSearchParams([ - ['timeFrom', timeFrom.toISOString()], - ['timeTo', timeTo.toISOString()], - ]), + ["timeFrom", timeFrom.toISOString()], + ["timeTo", timeTo.toISOString()], + ]) ); setSiteSummary(_siteSummary); })(); @@ -294,9 +342,9 @@ const MeasurementMap = ({ // TODO: MOVE TO UTILS; const greenIcon = new L.Icon({ iconUrl: - 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png', + "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png", shadowUrl: - 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png", iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], @@ -304,9 +352,9 @@ const MeasurementMap = ({ }); const goldIcon = new L.Icon({ iconUrl: - 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-gold.png', + "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-gold.png", shadowUrl: - 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png", iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], @@ -314,9 +362,9 @@ const MeasurementMap = ({ }); const redIcon = new L.Icon({ iconUrl: - 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png', + "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png", shadowUrl: - 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png", iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], @@ -327,35 +375,35 @@ const MeasurementMap = ({ const _markers = new Map(); const _sites: Site[] = allSites || []; if (!isSiteArray(_sites)) { - throw new Error('data has incorrect type'); + throw new Error("data has incorrect type"); } blayer.clearLayers(); for (let site of _sites) { if (site.boundary) { - L.polygon(site.boundary, { color: site.color ?? 'black' }).addTo( - blayer, + L.polygon(site.boundary, { color: site.color ?? "black" }).addTo( + blayer ); console.log(site.boundary); } _markers.set( site.name, - siteMarker(site, siteSummary[site.name], map).addTo(slayer), + siteMarker(site, siteSummary[site.name], map).addTo(slayer) ); } _markers.forEach((marker, site) => { - if (selectedSites.some(s => s.label === site)) { + if (selectedSites.some((s) => s.label === site)) { marker.setOpacity(1); } else { marker.setOpacity(0.5); } - if (allSites.some(s => s.name === site && s.status === 'active')) { + if (allSites.some((s) => s.name === site && s.status === "active")) { marker.setIcon(greenIcon); } else if ( - allSites.some(s => s.name === site && s.status === 'confirmed') + allSites.some((s) => s.name === site && s.status === "confirmed") ) { marker.setIcon(goldIcon); } else if ( - allSites.some(s => s.name === site && s.status === 'in-conversation') + allSites.some((s) => s.name === site && s.status === "in-conversation") ) { marker.setIcon(redIcon); } @@ -368,10 +416,10 @@ const MeasurementMap = ({ setMarkerData([]); return; } - const markerRes = await axios.get(API_URL + '/api/markers', { + const markerRes = await axios.get(API_URL + "/api/markers", { params: { - sites: selectedSites.map(ss => ss.label).join(','), - devices: selectedDevices.map(ss => ss.label).join(','), + sites: selectedSites.map((ss) => ss.label).join(","), + devices: selectedDevices.map((ss) => ss.label).join(","), timeFrom: timeFrom.toISOString(), timeTo: timeTo.toISOString(), }, @@ -384,12 +432,12 @@ const MeasurementMap = ({ if (!map || !markerData || !llayer) return; llayer.clearLayers(); const _markers = new Map(); - markerData.forEach(m => - _markers.set(m.mid, siteSmallMarker(m).addTo(llayer)), + markerData.forEach((m) => + _markers.set(m.mid, siteSmallMarker(m).addTo(llayer)) ); const smallIcon = new L.Icon({ iconUrl: - 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-grey.png', + "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-grey.png", // shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', iconSize: [20, 35], iconAnchor: [12, 35], @@ -411,20 +459,20 @@ const MeasurementMap = ({ setBins( await fetchToJson( API_URL + - '/api/data?' + + "/api/data?" + new URLSearchParams([ - ['width', bounds.width + ''], - ['height', bounds.height + ''], - ['left', bounds.left + ''], - ['top', bounds.top + ''], - ['binSizeShift', BIN_SIZE_SHIFT + ''], - ['zoom', DEFAULT_ZOOM + ''], - ['selectedSites', selectedSites.map(ss => ss.label).join(',')], - ['mapType', mapType], - ['timeFrom', timeFrom.toISOString()], - ['timeTo', timeTo.toISOString()], - ]), - ), + ["width", bounds.width + ""], + ["height", bounds.height + ""], + ["left", bounds.left + ""], + ["top", bounds.top + ""], + ["binSizeShift", BIN_SIZE_SHIFT + ""], + ["zoom", DEFAULT_ZOOM + ""], + ["selectedSites", selectedSites.map((ss) => ss.label).join(",")], + ["mapType", mapType], + ["timeFrom", timeFrom.toISOString()], + ["timeTo", timeTo.toISOString()], + ]) + ) ); })(); }, [ @@ -444,15 +492,15 @@ const MeasurementMap = ({ setLoading(true); (async () => { const colorDomain = [ - d3.max(bins, d => d[1] * MULTIPLIERS[mapType]) ?? 1, - d3.min(bins, d => d[1] * MULTIPLIERS[mapType]) ?? 0, + d3.max(bins, (d) => d[1] * MULTIPLIERS[mapType]) ?? 1, + d3.min(bins, (d) => d[1] * MULTIPLIERS[mapType]) ?? 0, ]; const colorScale = d3.scaleSequential(colorDomain, d3.interpolateViridis); setCDomain(colorDomain); layer.clearLayers(); - bins.forEach(p => { + bins.forEach((p) => { const idx = p[0]; const bin = Number(p[1]); if (bin) { @@ -462,7 +510,7 @@ const MeasurementMap = ({ const sw = map.unproject([x, y], DEFAULT_ZOOM); const ne = map.unproject( [x + (1 << BIN_SIZE_SHIFT), y + (1 << BIN_SIZE_SHIFT)], - DEFAULT_ZOOM, + DEFAULT_ZOOM ); L.rectangle(L.latLngBounds(sw, ne), { @@ -471,10 +519,10 @@ const MeasurementMap = ({ stroke: false, }) .bindTooltip(`${bin.toFixed(2)} ${UNITS[mapType]}`, { - direction: 'top', + direction: "top", }) .addTo(layer) - .on('click', e => { + .on("click", (e) => { const cs = cells; const c = cts({ x: x, y: y }); if (cs.has(c)) { @@ -507,7 +555,7 @@ const MeasurementMap = ({ mlayer.clearLayers(); var binSum: number = 0; var binNum: number = 0; - bins.forEach(p => { + bins.forEach((p) => { const idx = p[0]; const bin = Number(p[1]); if (bin) { @@ -517,19 +565,19 @@ const MeasurementMap = ({ if (cells.has(c)) { const ct = map.unproject( [x + (1 << BIN_SIZE_SHIFT) / 2, y + (1 << BIN_SIZE_SHIFT) / 2], - DEFAULT_ZOOM, + DEFAULT_ZOOM ); binSum += bin; binNum += 1; L.circle(L.latLng(ct), { - fillColor: '#FF0000', + fillColor: "#FF0000", fillOpacity: 0.75, radius: 24, stroke: false, }) - .bindTooltip(`${bin.toFixed(2)}`, { direction: 'top' }) + .bindTooltip(`${bin.toFixed(2)}`, { direction: "top" }) .addTo(mlayer) - .on('click', e => { + .on("click", (e) => { const cs = cells; if (cs.has(c)) { cs.delete(c); @@ -559,12 +607,12 @@ const MeasurementMap = ({ ]); return ( -
+
-
+
+ + { + setDisplayOptions( + solveDisplayOptions(displayOptions, 'displayGraph', true), + ); + }} + > + + + +