diff --git a/.gitignore b/.gitignore index 9dbc1483..08f8ef87 100644 --- a/.gitignore +++ b/.gitignore @@ -119,6 +119,7 @@ docker/archive/package-lock.json # open street map files *.pbf +mapnik.xml # docker override docker-compose.override.yml diff --git a/50x.html b/50x.html index 2d1cf8de..c0371e9a 100644 --- a/50x.html +++ b/50x.html @@ -21,7 +21,28 @@

Can't start the services?

-

You can view the media directory by clicking here

+

You can view the media directory by navigating to /media

+ +

The map may be available by navigating to port 8084

+ + diff --git a/README.md b/README.md index 399377a7..cb190df7 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ network so that any person with a laptop/tablet/phone can connect and use the da - [x] Food Inventory management - [x] Wi-Fi Hotspot - [x] Universal search -- [ ] Map viewer & downloader +- [x] Map viewer & downloader - [ ] eBook viewer - [ ] Wiki viewer & downloader - [ ] Synchronizer and/or duplicator @@ -51,9 +51,12 @@ You can try out WROLPi by running the docker containers. * `docker-compose up -d db` 5. Initialize the database * `docker-compose run --rm api db upgrade` -6. Start the docker containers +6. Create volumes + * `docker volume create --name=openstreetmap-data` + * `docker volume create --name=openstreetmap-rendered-tiles` +7. Start the docker containers * `docker-compose up` -7. Navigate to WROLPi +8. Navigate to WROLPi * http://0.0.0.0:8080 # RaspberryPi Install diff --git a/alembic/versions/2fb39c1861c7_create_mapfile_table.py b/alembic/versions/2fb39c1861c7_create_mapfile_table.py new file mode 100644 index 00000000..3f362f0c --- /dev/null +++ b/alembic/versions/2fb39c1861c7_create_mapfile_table.py @@ -0,0 +1,40 @@ +"""Create MapFile table. + +Revision ID: 2fb39c1861c7 +Revises: 36cae041ad31 +Create Date: 2022-03-03 19:13:39.997580 + +""" +import os + +from alembic import op +from sqlalchemy.orm import Session + +# revision identifiers, used by Alembic. +revision = '2fb39c1861c7' +down_revision = '36cae041ad31' +branch_labels = None +depends_on = None + +DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False + + +def upgrade(): + bind = op.get_bind() + session = Session(bind=bind) + + session.execute('''CREATE TABLE map_file ( + id SERIAL PRIMARY KEY, + path TEXT NOT NULL, + imported BOOLEAN DEFAULT FALSE, + size BIGINT + )''') + + if not DOCKERIZED: + session.execute('ALTER TABLE public.map_file OWNER TO wrolpi') + + +def downgrade(): + bind = op.get_bind() + session = Session(bind=bind) + session.execute('DROP TABLE IF EXISTS map_file') diff --git a/app/package.json b/app/package.json index 829604d4..8b32f9aa 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,6 @@ "version": "0.1.0", "private": true, "dependencies": { - "dot-prop": "^5.1.1", "lodash": "^4.17.19", "npm": "^6.14.7", "query-string": "^6.13.0", @@ -16,8 +15,7 @@ "react-semantic-toasts": "^0.6.5", "react-timezone-select": "0.8.4", "semantic-ui-offline": "^2.4.1-1", - "semantic-ui-react": "^0.88.2", - "yargs-parser": "^18.1.2" + "semantic-ui-react": "^0.88.2" }, "scripts": { "start": "react-scripts start", diff --git a/app/src/App.js b/app/src/App.js index 9c58d903..2ed6b604 100644 --- a/app/src/App.js +++ b/app/src/App.js @@ -15,6 +15,7 @@ import {Saver} from "./components/Upload"; import {MoreButton, PageContainer, SearchInput} from "./components/Common"; import {FilesRoute} from "./components/Files"; import {useSearch, useVersion} from "./hooks/customHooks"; +import {MapRoute} from "./components/Map"; function Welcome() { const {searchStr, setSearchStr, archives, videos} = useSearch(); @@ -88,6 +89,7 @@ export default function App() { + diff --git a/app/src/api.js b/app/src/api.js index ad7e52b2..bcda0b2b 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -388,7 +388,6 @@ export async function getDownloaders() { let response = await apiGet(`${API_URI}/downloaders`); return await response.json(); } catch (e) { - console.log('getDownloaders', e); return {}; } } @@ -512,3 +511,23 @@ export async function setThrottle(on) { } } } + +export async function getMapImportStatus() { + let response = await apiGet(`${API_URI}/map/pbf`); + return await response.json(); +} + +export async function importPbfs(pbfs) { + let body = {'pbfs': pbfs}; + let response = await apiPost(`${API_URI}/map/import`, body); + if (response.status === 204) { + return null; + } else { + toast({ + type: 'error', + title: 'Error!', + description: 'Could not start import! See server logs.', + time: 5000, + }); + } +} diff --git a/app/src/components/Admin.js b/app/src/components/Admin.js index 0e2a1084..51bf1cec 100644 --- a/app/src/components/Admin.js +++ b/app/src/components/Admin.js @@ -328,6 +328,11 @@ class Downloads extends React.Component { async componentDidMount() { await this.fetchDownloads(); + this.intervalId = setInterval(this.fetchDownloads, 1000 * 10); + } + + componentWillUnmount() { + clearInterval(this.intervalId); } closeStop = () => { diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 84d59b86..d3e8f0ef 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -1,10 +1,22 @@ import React, {useEffect, useState} from "react"; -import {Card, Checkbox, Container, Form, Icon, Image, Input, Menu, Pagination, Responsive} from 'semantic-ui-react'; +import { + Card, + Checkbox, + Container, + Form, + Icon, + Image, + Input, + Menu, + Pagination, + Popup, + Responsive +} from 'semantic-ui-react'; import {Link, NavLink} from "react-router-dom"; import Button from "semantic-ui-react/dist/commonjs/elements/Button"; import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; -import {getConfig, getSettings} from "../api"; +import {getSettings} from "../api"; import {useDownloaders, useHotspot, useThrottle} from "../hooks/customHooks"; export const API_URI = `http://${window.location.host}/api`; @@ -256,15 +268,13 @@ export function secondsToTimestamp(seconds) { } export function humanFileSize(bytes, si = false, dp = 1) { - const thresh = si ? 1000 : 1024; + const thresh = 1024; if (Math.abs(bytes) < thresh) { return bytes + ' B'; } - const units = si - ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] - : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + const units = ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; let u = -1; const r = 10 ** dp; @@ -685,3 +695,23 @@ export function emptyToNull(obj) { } return obj; } + +export function HelpPopup({icon, size, content, position}) { + return } + /> +} + +export function minutesToTimestamp(minutes) { + minutes = Math.round(minutes); + const hours = String(Math.floor(minutes / 60)).padStart(1, '0'); + minutes = minutes - (hours * 60); + minutes = String(minutes).padStart(2, '0'); + return `${hours}:${minutes}` +} diff --git a/app/src/components/Map.js b/app/src/components/Map.js new file mode 100644 index 00000000..ccf1738f --- /dev/null +++ b/app/src/components/Map.js @@ -0,0 +1,250 @@ +import React from "react"; +import {HelpPopup, humanFileSize, minutesToTimestamp, PageContainer, TabLinks, WROLModeMessage} from "./Common"; +import {Route} from "react-router-dom"; +import {getMapImportStatus, importPbfs} from "../api"; +import Table from "semantic-ui-react/dist/commonjs/collections/Table"; +import {Button, Checkbox, Icon, Loader, Placeholder} from "semantic-ui-react"; +import Message from "semantic-ui-react/dist/commonjs/collections/Message"; + +class ManageMap extends React.Component { + constructor(props) { + super(props); + this.state = { + pbfs: null, + importing: null, + import_running: false, + selectedPaths: [], + dockerized: null, + } + } + + async componentDidMount() { + await this.fetchImportStatus(); + this.intervalId = setInterval(this.fetchImportStatus, 1000 * 30); + } + + componentWillUnmount() { + clearInterval(this.intervalId); + } + + fetchImportStatus = async () => { + const importStatus = await getMapImportStatus(); + this.setState({ + pbfs: importStatus['pbfs'], + importing: importStatus['importing'], + import_running: importStatus['import_running'], + dockerized: importStatus['dockerized'], + }); + } + + import = async (e) => { + e.preventDefault(); + + this.setState({'ready': false}); + const {pbfs, selectedPaths} = this.state; + if (!selectedPaths || selectedPaths.length === 0) { + // No PBFs are selected, import all. + let paths = []; + for (let i = 0; i < pbfs.length; i++) { + paths = paths.concat([pbfs[i].path]); + } + await importPbfs(paths); + } else { + await importPbfs(selectedPaths); + } + await this.fetchImportStatus(); + } + + handleCheckbox = (checked, pbf) => { + let {selectedPaths} = this.state; + if (checked === true) { + selectedPaths = selectedPaths.concat([pbf.path]); + } else { + const index = selectedPaths.indexOf(pbf.path); + if (index > -1) { + selectedPaths.splice(index, 1); + } + } + this.setState({selectedPaths}); + } + + tableRow = (pbf, disabled) => { + let ref = React.createRef(); + const {size, path, imported} = pbf; + const raspberryPi4Estimation = 3000000; + let sizeCells = (<> + {humanFileSize(size)} + {humanFileSize(size * 25)} + {minutesToTimestamp(size / raspberryPi4Estimation)} + ); + return + + this.handleCheckbox(data.checked, pbf)} + /> + + + {path} + + + {path === this.state.importing ? + : + imported ? 'yes' : 'no'} + + {size !== null ? sizeCells : } + + } + + render() { + const {pbfs, selectedPaths, import_running, importing, dockerized} = this.state; + + let dockerWarning; + if (dockerized === true) { + dockerWarning = + + + + Maps are not fully supported in a Docker container! + + +

Only one PBF can be imported and displayed in the docker container.

+ +

To import a map file, run the following docker-compose commands:

+
  docker-compose stop map
+
  docker-compose rm map
+
  docker-compose run --rm -v /absolute/path/to/map.osm.pbf:/data.osm.pbf
+                        -v openstreetmap-data:/var/lib/postgresql/12/main map import
+                    
+ +

Be sure to change /absolute/path/to/map.osm.pbf!

+ +

After you have imported a new PBF file, you need to clear the rendered tile cache:

+
  docker volume rm openstreetmap-rendered-tiles
+
  docker volume create openstreetmap-rendered-tiles
+ +

Start your map container:

+
  docker-compose up -d map
+
+
+ } + + let downloadMessage = + + + + Where do I get map files? + + +

You can download map files from  + https://download.geofabrik.de/ +

+ +

Download only the areas you need. Large regions like all of Asia, or the entire + planet are most likely too large and won't render quickly. I recommend only + importing files less than 1GB on a Raspberry Pi.

+ +

Only *.osm.pbf files are supported!

+ +

Place downloaded map files into map/pbf so they can be imported here.

+
+
+ + let importingLoader = ( + Importing: {importing} + ); + + let disabled = dockerized || !pbfs || pbfs.length === 0 || import_running; + let importButton = ; + + let rows; + if (!pbfs) { + // Fetch request is not complete. + rows = + + + + + + + + ; + } else if (pbfs.length === 0) { + rows = + No PBF map files were found in map/pbf + ; + } else { + rows = pbfs.map(i => this.tableRow(i, disabled)); + } + + let spaceHelpPopup = ; + let timeHelpPopup = ; + + return ( + + + {dockerWarning} + {downloadMessage} + {importingLoader} + + + + + PBF File + Imported + Size + Space Required {spaceHelpPopup} + Time to Import {timeHelpPopup} + + + + {rows} + + + + + + {importButton} + + + +
+
+ ); + } +} + +function MapApp() { + return ( +