82 Commits

Author SHA1 Message Date
Linus Groh
fac0479b25 Release 2.0.0-beta.3 2019-12-13 19:53:31 +00:00
Linus Groh
b2edda410f Add German translations 2019-12-13 19:45:53 +00:00
Linus Groh
73465268e2 Add i18 support 2019-12-13 19:45:21 +00:00
Linus Groh
4e449235b2 Add custom checkbox focus style 2019-12-13 18:41:27 +00:00
Linus Groh
1a7f969b59 Fix checkbox style issues 2019-12-13 18:41:02 +00:00
Linus Groh
e7e6ea7dda Fix hover/focus inconsistencies 2019-12-13 18:40:14 +00:00
Linus Groh
9f522dd727 Reimplement layer settings dropdown 2019-12-13 18:39:33 +00:00
Linus Groh
76e8a56cc7 Mention LISTEN_PORT environment variable in docs 2019-12-13 18:37:33 +00:00
Linus Groh
012eb74837 Update README.md 2019-12-11 22:25:59 +00:00
Linus Groh
207a63c0d8 Upgrade dependencies 2019-12-11 22:12:45 +00:00
Linus Groh
bbc381e70c Update README.md 2019-11-07 19:23:53 +00:00
Linus Groh
de45906860 Add drop shadow to window screenshot 2019-11-06 22:36:59 +00:00
Linus Groh
1734ef7c74 Update screenshots 2019-11-06 22:21:32 +00:00
Linus Groh
c4d368eee9 Fix Docker image labels
The LABEL instructions in docker/Dockerfile were not applied to the
final image, it only had the ones inherited from the nginx image.
2019-11-02 19:18:56 +00:00
Linus Groh
f0ff18c792 Add Travis CI build status badge 2019-11-02 18:23:49 +00:00
Linus Groh
220bda6ef3 Release 2.0.0-beta.2 2019-11-02 18:17:48 +00:00
Linus Groh
6209c806a2 Upgrade dependencies 2019-11-02 17:30:13 +00:00
Linus Groh
c85e6fedf2 Fix timezone issues in tests 2019-11-02 17:25:17 +00:00
Linus Groh
edff370dc8 Enable Travis CI 2019-11-02 16:54:59 +00:00
Linus Groh
69edbc6ce4 Fix typo 2019-11-02 16:34:11 +00:00
Linus Groh
76f1d4980c Add onLocationChange.reloadHistory config option 2019-11-01 22:37:51 +00:00
Linus Groh
39fd7727f4 Upgrade dependencies 2019-10-27 12:34:01 +00:00
Linus Groh
418a2fe808 Fix ESLint errors 2019-10-27 12:28:29 +00:00
Linus Groh
f0c4ba43cb Fix config TOC links 2019-10-26 22:52:42 +01:00
Linus Groh
5d6208d57a Release 2.0.0-beta.1 2019-10-26 22:39:18 +01:00
Linus Groh
bd25881199 Fix branch name for version badge in README.md 2019-10-26 21:51:15 +01:00
Linus Groh
d029fb5360 Merge pull request #16 from owntracks/v2.0.0-alpha
v2.0.0 alpha
2019-10-26 21:48:31 +01:00
Linus Groh
de3d83e28f Add volume for config.js to docker-compose example 2019-10-26 19:33:09 +01:00
Linus Groh
9bd7fb8681 Upgrade dependencies 2019-10-26 19:20:06 +01:00
Linus Groh
9dbf6e78f1 Fix build by upgrading eslint-loader 2019-10-26 19:13:50 +01:00
Linus Groh
d5e21a2ada Upgrade Vue CLI packages 2019-10-26 19:08:50 +01:00
Linus Groh
942df6d001 Merge branch 'master' into v2.0.0-alpha 2019-10-26 18:34:10 +01:00
Linus Groh
5ffe6025ae Rename config.default.js to config.example.js 2019-10-26 18:11:54 +01:00
Linus Groh
037b140311 Add newline to end of .dockerignore 2019-10-26 18:04:57 +01:00
Linus Groh
5a24cac5a1 Add .dockerignore 2019-10-26 17:57:39 +01:00
Linus Groh
3444b75345 Update Dockerfile for v2 2019-10-26 17:57:33 +01:00
Linus Groh
1c05bb17b4 Fix button text overflow 2019-10-22 22:13:12 +01:00
Linus Groh
f14f97b416 Add verbose mode 2019-10-22 22:12:59 +01:00
Linus Groh
50a513d144 Add custom checkbox styles 2019-10-22 20:19:13 +01:00
Linus Groh
31101a9818 Update cors-proxy instructions to use .env file 2019-10-22 19:55:25 +01:00
Linus Groh
28803bfd2d Add version and license badges 2019-10-21 13:59:27 +02:00
Linus Groh
da7a0aa5d6 Update distanceBetweenCoordinates 2019-10-02 19:23:45 +01:00
Linus Groh
12910fe66d Fix comment in test 2019-10-02 19:06:31 +01:00
Linus Groh
193882c4e7 Remove console.log 2019-10-02 19:04:27 +01:00
Linus Groh
968355cfb7 Use v-bind for LCircle 2019-10-02 19:04:09 +01:00
Linus Groh
c6181c77b1 Update heatmap when latLng changes 2019-10-02 19:03:36 +01:00
Linus Groh
9e61b7f174 Fix icon alignment 2019-10-02 19:03:12 +01:00
Linus Groh
432ec4bac4 Add loading indicator 2019-10-02 19:02:29 +01:00
Linus Groh
64a820a218 Add gradient config support to heatmap 2019-10-01 22:03:30 +01:00
Linus Groh
96a0daa05e Fix heatmap 2019-10-01 21:56:37 +01:00
Linus Groh
3571ac2724 Add "Loading version..." label to info modal 2019-10-01 21:21:41 +01:00
Linus Groh
5ff89c5484 Add OwnTracks website to info modal 2019-10-01 21:18:34 +01:00
Linus Groh
4a64b939be Upgrade outdated dependencies 2019-10-01 21:01:01 +01:00
Linus Groh
efbf980924 Implement data copying & download 2019-10-01 20:56:51 +01:00
Linus Groh
6c6763ebfc Refactor modals 2019-10-01 19:23:15 +01:00
Linus Groh
874847d22f Don't cut of button / date select labels 2019-10-01 19:02:39 +01:00
Linus Groh
937bafc3f0 Add tests for API functions 2019-09-30 19:10:11 +01:00
Linus Groh
506f12b66e Update JSDoc type definitions 2019-09-29 08:10:28 +01:00
Linus Groh
98cb52b31b Add JSDoc to more functions 2019-09-29 08:01:45 +01:00
Linus Groh
03ecce52af Fix typo 2019-09-29 08:00:29 +01:00
Linus Groh
be63a12607 Clean up and document configuration options 2019-09-29 08:00:16 +01:00
Linus Groh
69619665f4 Add linebreaks between lat lon alt 2019-09-28 22:32:57 +01:00
Linus Groh
b78d915751 Update location popup 2019-09-28 22:05:01 +01:00
Linus Groh
a12290d343 Enhance outline & flat button accessibility 2019-09-28 19:25:31 +01:00
Linus Groh
cf993d11dd Update information modal 2019-09-28 19:16:29 +01:00
Linus Groh
a443393bba Increase base font size to 14px 2019-09-28 19:06:24 +01:00
Linus Groh
4d06e1c07e Use vue-js-modal for modals 2019-09-28 19:01:57 +01:00
Linus Groh
31a85e42a6 Refactor styles 2019-09-28 14:50:05 +01:00
Linus Groh
81d9b63dd4 Reset selected device when changing user 2019-09-28 14:06:37 +01:00
Linus Groh
2d9ad44a23 Ignore selectedDevice if selectedUser is null 2019-09-28 14:06:12 +01:00
Linus Groh
4fc79adf81 Update default config comments 2019-09-28 12:33:21 +01:00
Linus Groh
6d4ff0d96b Rename accentColor to primaryColor 2019-09-28 12:32:59 +01:00
Linus Groh
d01de41e1f Return URL instance from getApiUrl 2019-09-27 22:12:49 +01:00
Linus Groh
600934183a Add tests 2019-09-27 21:13:40 +01:00
Linus Groh
a2de94fd44 Fix getApiUrl implementation 2019-09-27 20:52:16 +01:00
Linus Groh
4eb89abf3d Remove unused util function 2019-09-27 20:25:54 +01:00
Linus Groh
87647c81d3 Add config option to ignore ping/ping 2019-09-27 19:46:34 +01:00
Linus Groh
03712ef2a1 Add reloadData action 2019-09-27 19:33:55 +01:00
Linus Groh
c64ddd9e18 Fix typo 2019-09-27 19:26:27 +01:00
Linus Groh
75c3462eae Clarify cors-proxy usage 2019-09-27 19:19:38 +01:00
Linus Groh
9f35dbd5f2 Update README.md 2019-09-27 19:16:00 +01:00
Linus Groh
4d971d57f7 Publish 2.0.0-alpha source 2019-09-27 18:34:41 +01:00
73 changed files with 14812 additions and 1053 deletions

3
.browserslistrc Normal file
View File

@@ -0,0 +1,3 @@
> 1%
last 2 versions
not ie > 0

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
node_modules
docs
scripts
tests
LICENSE
README.md
*Dockerfile*
*docker-compose*

30
.eslintrc.js Normal file
View File

@@ -0,0 +1,30 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: ["plugin:vue/essential", "@vue/prettier"],
rules: {
"no-console": process.env.NODE_ENV === "production" ? "error" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "off",
"prettier/prettier": [
"error",
{
trailingComma: "es5",
printWidth: 80,
htmlWhitespaceSensitivity: "ignore",
},
],
},
parserOptions: {
parser: "babel-eslint",
},
overrides: [
{
files: ["**/__tests__/*.{j,t}s?(x)"],
env: {
jest: true,
},
},
],
};

23
.gitignore vendored
View File

@@ -1,3 +1,26 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
public/config/config.js
# Logs
logs
*.log

6
.travis.yml Normal file
View File

@@ -0,0 +1,6 @@
language: node_js
node_js:
- 10
cache: yarn
script:
- yarn test

View File

@@ -1,3 +1,50 @@
# 2.0.0-beta.3 (2019-12-13)
- Add i18 support (currently English and German, `locale` config option)
- Add custom checkbox focus style
- Fix layer dropdown issues
- Fix checkbox style issues
- Fix hover/focus inconsistencies
- Fix Docker image labels
- `README.md` enhancements
- Upgrade dependencies
# 2.0.0-beta.2 (2019-11-02)
- Add `onLocationChange.reloadHistory` config option
- Add Travis CI config
- Fix timezone issues in tests
- Fix ESLint errors in production mode
- Fix table of content links in config documentation
- Upgrade dependencies
# 2.0.0-beta.1 (2019-10-26)
- Convert codebase to Node.js based development workflow, including:
- Package management using yarn
- Build step using Webpack and Babel
- Usage of Vue single file components
- SCSS and PostCSS
- ESLint configuration for linting and consistent code style
- `package.json` scripts: `serve`, `build`, `lint`, `cors-proxy` and `test`
- Design updates, including:
- New default primary color (same as OwnTracks Android app)
- Improved hover and focus styles as a first attempt to improve accessibility
- Improved modals and location popups
- Custom checkbox styles
- Switch from Font Awesome 4 to Feather Icons
- Application now uses Vuex and Vue Router
- Add URL query parameters to load and preserve application state: `lat`, `lng`, `zoom`, `start`, `end`, `user`, `device` and `layers`.
- Add a loading indicator.
- Add 'download data' modal, currently supporting formatted and minified JSON.
- Add a verbose mode.
- Add CORS proxy script toeasily use a production instance of the OwnTracks recorder in development.
- Add unit tests for util and API functions.
- Add documentation for all public funtions
- Add documentation for all configuration options.
- Add more configuration options, including setting the API base URL ([#4](https://github.com/owntracks/frontend/issues/4)) and hiding the `ping/ping` location ([#12](https://github.com/owntracks/frontend/issues/12)).
# 1.1.0 (2019-10-26)
- Add support for Docker. [#7](https://github.com/owntracks/frontend/pull/7), [@sharkoz](https://github.com/sharkoz)

View File

@@ -1,11 +0,0 @@
FROM nginx:alpine
EXPOSE 80
ENV LISTEN_PORT=80 \
SERVER_HOST=otrecorder \
SERVER_PORT=80
COPY nginx.tmpl /etc/nginx/nginx.tmpl
COPY index.html /usr/share/nginx/html
COPY static/ /usr/share/nginx/html/static/
CMD /bin/sh -c "envsubst '\${SERVER_HOST} \${SERVER_PORT} \${LISTEN_PORT}' < /etc/nginx/nginx.tmpl > /etc/nginx/nginx.conf && nginx -g 'daemon off;' || ( env; cat /etc/nginx/nginx.conf )"

163
README.md
View File

@@ -1,31 +1,49 @@
# OwnTracks UI
> A modern web interface for OwnTracks made with Vue.js
![Version](https://img.shields.io/github/package-json/v/owntracks/frontend)
[![Docker Pulls](https://img.shields.io/docker/pulls/owntracks/frontend)](https://hub.docker.com/r/owntracks/frontend)
[![Build Status](https://travis-ci.org/owntracks/frontend.svg?branch=master)](https://travis-ci.org/owntracks/frontend)
[![License](https://img.shields.io/github/license/owntracks/frontend?color=d63e97)](https://github.com/owntracks/frontend/blob/master/LICENSE)
<p style="text-align: center;">
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/owntracks-ui.png" alt="OwnTracks UI">
</p>
![OwnTracks UI](https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/owntracks-ui.png)
## Introduction
This is a web interface for [OwnTracks](https://github.com/owntracks/recorder), intended to replace the various web pages shipping with the recorder. OwnTracks UI uses Vue.js under the hood.
This is a web interface for [OwnTracks](https://github.com/owntracks/recorder) built as
a Vue.js single page application. The recorder itself already ships with some basic web
pages, this is a more advanced interface with more functionality, all in one place.
![Map features](https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/map-features.png)
## Features
- Last known (i.e. live) locations:
- Accuracy visualization (circle)
- Device friendly name and icon
- Detailed information (if available): time, latitude, longitude, height, battery and
speed
- Location history (data points, line or both)
- Location heatmap
- Quickly fit all shown objects on the map into view
- Display data in a specific date range
- Filter by user and device
- Download selected location data as JSON
- Highly customisable
## Installation
### Manual install
Clone the repository and copy `index.html` and the `static/` directory to your server's webroot. The API is expected to be reachable under the same domain as the web interface.
### Docker
You can launch directly via Docker run like this:
A pre-built Docker image is available on Docker Hub as [`owntracks/frontend`](https://hub.docker.com/r/owntracks/frontend).
You can start a container directly via `docker run`:
```console
$ docker run -d -p 80:80 -e SERVER_HOST=otrecorder-host -e SERVER_PORT=otrecorder-port owntracks/frontend
$ docker run -d -p 80:80 -e SERVER_HOST=otrecorder-host -e SERVER_PORT=8083 owntracks/frontend
```
Or you can use `docker-compose` (if you also run the OwnTracks Recorder with the default compose config, and the service is named `otrecorder`):
Or you can use `docker-compose` (if you also run the OwnTracks Recorder with the default
compose config, and the service is named `otrecorder`):
```yaml
version: '3'
@@ -35,61 +53,104 @@ services:
image: owntracks/frontend
ports:
- 80:80
volumes:
- ./path/to/custom/config.js:/usr/share/nginx/html/config/config.js
environment:
- SERVER_HOST=otrecorder
- SERVER_PORT=8083
restart: unless-stopped
```
## Features
To change the port on which the nginx server will listen on, set the
`LISTEN_PORT` enviroment variable - default is 80.
- Enable or disable multiple layers:
To build the image from source replace `image:` with:
- Last known (i.e. live) locations:
```yaml
build:
context: ./owntracks-frontend
dockerfile: docker/Dockerfile
```
- Accuracy visualization (circle)
- Device friendly name and icon
- Detailed information (if available): time, lat, lon, height, battery and speed
(assuming you have this repository cloned to `owntracks-frontend` in the same
directory as `docker-compose.yml`)
- Location history (data points, line or both)
- Location heatmap
- Button to quickly fit all shown objects on the map into view
### Manually
- Display data in a specific date range
- Filter by user and device
- Customizable:
- Run `yarn install --production` to install dependencies
- Run `yarn build` to compile and minify for production
- Copy the content of the `dist/` directory to your webroot
- UI color
- Default start and end date
- Map:
## Configuration
- Tile server
- Max zoom
- Default position and zoom
- Heatmap colors, radius and blur
It's possible to get started without any configuration change whatsoever, assuming your
OwnTracks API is reachable at the root of the same host as the frontend.
Copy [`public/config/config.example.js`](public/config/config.example.js) to
`public/config/config.js` and make changes as you wish.
See [`docs/config.md`](docs/config.md) for all available options.
## Development
- Run `yarn install` to install dependencies
- Run `yarn serve` to compile for development and start the hot-reload server
- Run `yarn lint` to lint and fix files
- Run `yarn test` to run unit tests
### CORS-Proxy
You can use the [`corsProxy.js`](scripts/corsProxy.js) script to use your production
instance of OwnTracks for development without making changes to its CORS-Headers:
```console
$ yarn cors-proxy
```
If you have [basic authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Basic_authentication_scheme)
enabled, create a `.env` file with your credentials:
```text
OT_BASIC_AUTH_USERNAME=user
OT_BASIC_AUTH_PASSWORD='P@$$w0rd'
```
Then run:
```console
$ env $(cat .env | xargs) yarn cors-proxy
```
The default host and port it binds to is `0.0.0.0:8888`. Change using the `OT_PROXY_HOST`
and `OT_PROXY_PORT` environment variables.
Finally update `api.baseUrl` in your config to `"http://0.0.0.0:8888/https://owntracks.example.com"`.
### I18n
This project uses [Vue I18n](https://kazupon.github.io/vue-i18n/). To see missing and
unused i18n entries, run:
```console
$ yarn i18n:report
```
To add a new locale, copy `en.json` to `<locale>.json` in [`src/locales`](src/locales)
and start translating the individual strings. Make sure to add the new locale to the docs!
## Screenshots
_Click to enlarge._
<p align="center">
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/loading.gif" alt="Loading...">
<br>
<br>
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/downloader.png" alt="Download location data">
<br>
<br>
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/info.png" alt="Info">
</p>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/live.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/live.png" alt="Live" height="200"></a>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/multiple.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/multiple.png" alt="Multiple" height="200"></a>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/date-selection.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/date-selection.png" alt="Date selection" height="200"></a>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/heatmap.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/heatmap.png" alt="Heatmap" height="200"></a>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/customized.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/customized.png" alt="Customized" height="200"></a>
## ToDo
- Node.js based development workflow:
- Webpack
- Vue SFCs
- Sass
- Dependency management with yarn instead of a local copy or unpkg.com
- Add documentation, at least for the config file
- Download data for selected date range, user and device as JSON
## Contributing
Please feel free to open an issue and discuss your ideas and report bugs. If you think you can help out with something, open a PR!
Please feel free to open an issue and discuss your ideas and report bugs. If you think
you can help out with something, open a PR!

3
babel.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
};

24
docker/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:10 as build
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install
COPY . ./
RUN yarn build
FROM nginx:1.17-alpine
LABEL version="2.0.0-beta.3"
LABEL description="OwnTracks UI"
LABEL maintainer="Linus Groh <mail@linusgroh.de>"
ENV LISTEN_PORT=80 \
SERVER_HOST=otrecorder \
SERVER_PORT=80
COPY ./docker/nginx.tmpl /etc/nginx/nginx.tmpl
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
EXPOSE 80
CMD /bin/sh -c " \
envsubst '\${SERVER_HOST} \${SERVER_PORT} \${LISTEN_PORT}' \
< /etc/nginx/nginx.tmpl \
> /etc/nginx/nginx.conf \
&& nginx -g 'daemon off;' \
|| ( env; cat /etc/nginx/nginx.conf ) \
"

464
docs/config.md Normal file
View File

@@ -0,0 +1,464 @@
# Configuration
## Overview
All _custom_ configuation is stored in `window.owntracks.config`,
which is a regular JavaScript object - so you can use template strings, spread syntax,
comments and other JS features.
Some of the application state is synced to the URL's query parameters. If a parameter
exists in the URL query, it takes precedence over the configured value - otherwise the
configured value will be used and appended to the URL query.
Start with this:
```js
window.owntracks = window.owntracks || {};
window.owntracks.config = {};
```
**WARNING: if your configuration contains private data (most commonly your tile server**
**access key), make sure to protect access to it properly, e.g. with basic authentication.**
## Options
- `api`
- [`baseUrl`](#apibaseurl)
- [`endDate`](#enddate)
- [`ignorePingLocation`](#ignorepinglocation)
- [`locale`](#locale)
- `map`
- [`attribution`](#mapattribution)
- `center`
- [`lat`](#mapcenterlat)
- [`lng`](#mapcenterlng)
- [`circle`](#mapcircle)
- [`circleMarker`](#mapcirclemarker)
- `controls`
- `scale`
- [`display`](#mapcontrolsscaledisplay)
- [`imperial`](#mapcontrolsscaleimperial)
- [`maxWidth`](#mapcontrolsscalemaxwidth)
- [`metric`](#mapcontrolsscalemetric)
- [`position`](#mapcontrolsscaleposition)
- `zoom`
- [`display`](#mapcontrolszoomdisplay)
- [`position`](#mapcontrolszoomposition)
- `heatmap`
- [`blur`](#mapheatmapblur)
- [`gradient`](#mapheatmapgradient)
- [`max`](#mapheatmapmax)
- [`radius`](#mapheatmapradius)
- `layers`
- [`heatmap`](#maplayersheatmap)
- [`last`](#maplayerslast)
- [`line`](#maplayersline)
- [`points`](#maplayerspoints)
- [`maxNativeZoom`](#mapmaxnativezoom)
- [`maxPointDistance`](#mapmaxpointdistance)
- [`maxZoom`](#mapmaxzoom)
- [`polyline`](#mappolyline)
- [`url`](#mapurl)
- [`zoom`](#mapzoom)
- `onLocationChange`
- [`reloadHistory`](#onlocationchangereloadhistory)
- [`primaryColor`](#primarycolor)
- [`selectedDevice`](#selecteddevice)
- [`selectedUser`](#selecteduser)
- [`startDate`](#startdate)
- [`verbose`](#verbose)
### `api.baseUrl`
Base URL for the recorder's HTTP and WebSocket API. Keep CORS in mind.
- Type: [`String`]
- Default: current protocol and host
- Examples:
```js
// API requests will be made to https://owntracks.example.com/api/0/...
window.owntracks.config = {
api: {
baseUrl: "https://owntracks.example.com"
}
};
```
```js
// API requests will be made to https://example.com/owntracks/api/0/...
window.owntracks.config = {
api: {
baseUrl: "https://example.com/owntracks/"
}
};
```
### `endDate`
Initial end date for fetched data.
- Type: [`Date`]
- Default: today
- Example:
```js
// Data will be fetched up to 1970-01-01
window.owntracks.config = {
endDate: new Date(1970, 1, 1)
};
```
### `ignorePingLocation`
Remove the `ping/ping` location from the fetched data. This is useful when using the
`owntracks/recorder` Docker image which has it [enabled for health checks by default](https://github.com/owntracks/recorder/issues/195#issuecomment-304004436).
- Type: [`Boolean`]
- Default: `true`
- Example:
```js
// Don't ignore ping/ping location. Not sure why you'd do this :)
window.owntracks.config = {
ignorePingLocation: false
};
```
### `locale`
The language to use for the user interface. Available: `de` (German), `en` (English).
- Type: [`String`]
- Default: `"en"`
### `map.attribution`
Attribution for map tiles.
- Type: [`String`] (may contain HTML)
- Default: `"&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors"`
- Example:
```js
// Make sure to add proper attribution!
window.owntracks.config = {
map: {
attribution: "Map tiles &copy; MyTileServerProvider"
}
};
```
### `map.center.lat`
Initial map center latitude.
- Type: [`Number`]
- Default: `0`
### `map.center.lng`
Initial map center longitude.
- Type: [`Number`]
- Default: `0`
### `map.circle`
Location accuracy indicator configuation. `color` and `fillColor` default to
`primaryColor` if `null`. See [Vue2Leaflet `l-circle` documentation](https://korigan.github.io/Vue2Leaflet/#/components/l-circle/)
for all possible values.
- Type: [`Object`]
- Default:
```js
{
color: null,
fillColor: null,
fillOpacity: 0.2
}
```
### `map.circleMarker`
Location point marker configuation. `color` defaults to `primaryColor` if `null`. See
[Vue2Leaflet `l-circle-marker` documentation](https://korigan.github.io/Vue2Leaflet/#/components/l-circle-marker/)
for all possible values.
- Type: [`Object`]
- Default:
```js
{
color: null,
fillColor: "#fff",
fillOpacity: 1,
radius: 4
}
```
### `map.controls.scale.display`
Whether to show scale control or not.
- Type: [`Boolean`]
- Default: `true`
### `map.controls.scale.imperial`
Whether to show an imperial scale (ft) or not.
- Type: [`Boolean`]
- Default: `true`
### `map.controls.scale.maxWidth`
Maximum width of the scale control in pixels.
- Type: [`Number`]
- Default: `200`
### `map.controls.scale.metric`
Whether to show an metric scale (m) or not.
- Type: [`Boolean`]
- Default: `true`
### `map.controls.scale.position`
Scale control position on the map. See [Leaflet control position documentation](https://leafletjs.com/reference-1.5.0.html#control-position)
for all possible values.
- Type: [`String`]
- Default: `"bottomleft"`
### `map.controls.zoom.display`
Whether to show zoom control or not.
- Type: [`Boolean`]
- Default: `true`
### `map.controls.zoom.position`
Zoom control position on the map. See [Leaflet control position documentation](https://leafletjs.com/reference-1.5.0.html#control-position)
for all possible values.
- Type: [`String`]
- Default: `"topleft"`
### `map.heatmap.blur`
Heatmap blur radius.
- Type: [`Number`]
- Default: `15`
### `map.heatmap.gradient`
Mapping of values between 0 and 1 to different colors. Defaults to [`simpleheat`'s default gradient](https://github.com/mourner/simpleheat/blob/c1998c36fa2f9a31350371fd42ee30eafcc78f9c/simpleheat.js#L22-L28)
if `null`.
- Type: [`Object`] or `null`
- Default: `null`
### `map.heatmap.max`
Heatmap max data value.
- Type: [`Number`]
- Default: `20`
### `map.heatmap.radius`
Heatmap point radius.
- Type: [`Number`]
- Default: `25`
### `map.layers.heatmap`
Initial visibility of the heatmap layer.
- Type: [`Boolean`]
- Default: `false`
### `map.layers.last`
Initial visibility of the last locations layer.
- Type: [`Boolean`]
- Default: `true`
### `map.layers.line`
Initial visibility of the line layer.
- Type: [`Boolean`]
- Default: `true`
### `map.layers.points`
Initial visibility of the points layer.
- Type: [`Boolean`]
- Default: `false`
### `map.maxNativeZoom`
This is being used to fetch tiles in different resolutions - set to the highest value
the configured tileserver supports.
- Type: [`Number`]
- Default: `19`
### `map.maxPointDistance`
Maximum distance (in meters) between points for them to be part of the the same line.
This avoids straight lines going across the map when there's a ceartain distance between
two points (which often indicates that they're not related). Set to `null` to disable
splitting into separate lines.
- Type: [`Number`] or `null`
- Default: `null`
- Example:
```js
// Don't connect points with a distance of more than 1km
window.owntracks.config = {
map: {
maxPointDistance: 1000
}
};
```
### `map.maxZoom`
Allow zooming closer than the tile server supports, which will result in (slightly)
blurry tiles on higher zoom levels. Set to the same value as [`map.maxNativeZoom`](#map.maxNativeZoom)
to disable.
- Type: [`Number`]
- Default: `21`
### `map.polyline`
Location point marker configuation. `color` defaults to `primaryColor` if `null`. See
[Vue2Leaflet `l-polyline` documentation](https://korigan.github.io/Vue2Leaflet/#/components/l-polyline/)
for all possible values.
- Type: [`Object`]
- Default:
```js
{
color: null,
fillColor: "transparent"
}
```
### `map.url`
Tile server URL. For more information see [Leaflet tile layer documentation](https://leafletjs.com/reference-1.5.0.html#tilelayer-url-template)
and [this Wikipedia article](https://en.wikipedia.org/wiki/Tiled_web_map).
- Type: [`String`]
- Default: `"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"`
- Example:
```js
// Use dark HDPI tiles from Mapbox
window.owntracks.config = {
map: {
url: "https://api.mapbox.com/v4/mapbox.dark/{z}/{x}/{y}@2x.png?access_token=xxxxxxxxxxxxxxxx"
}
};
```
### `map.zoom`
Initial map zoom level.
- Type: [`Number`]
- Default: `19`
### `onLocationChange.reloadHistory`
Whether to reload the location history (of selected date range) or not when a location
update is received.
- Type: [`Boolean`]
- Default: `false`
### `primaryColor`
Primary color for the user interface (navigation bar and various map elements).
- Type: [`String`] ([CSS `<color>`])
- Default: `"#3f51b5"` (primary color from the OwnTracks Android app)
- Example:
```js
// Set the UI's primary color to 'rebeccapurple'
window.owntracks.config = {
primaryColor: "rebeccapurple"
};
```
### `selectedDevice`
Initial selected device. All devices will be shown by default if `null`. Will be ignored
if [`selectedUser`](#selectedUser) is `null`;
Only data for the selected user/device will be fetched, so you can use this to limit the
amount of data fetched after page load.
- Type: [`String`] or `null`
- Default: `null`
- Example:
```js
// Select the device 'phone' from user 'foo' by default
window.owntracks.config = {
selectedUser: "foo",
selectedDevice: "phone"
};
```
### `selectedUser`
Initial selected user. All users will be shown by default if `null`.
Only data for the selected user/device will be fetched, so you can use this to limit the
amount of data fetched after page load.
- Type: [`String`] or `null`
- Default: `null`
- Example:
```js
// Select all devices from user 'foo' by default
window.owntracks.config = {
selectedUser: "foo"
};
```
### `startDate`
Initial start date for fetched data.
- Type: [`Date`]
- Default: one month ago
- Example:
```js
// Data will be fetched from the first day of the current month
const startDate = new Date();
startDate.setUTCHours(0, 0, 0, 0);
startDate.setUTCDate(1);
window.owntracks.config = {
startDate
};
```
### `verbose`
Whether to enable verbose mode or not.
- Type: [`Boolean`]
- Default: `false`
[`Boolean`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean
[`Date`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date
[`Number`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number
[`Object`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object
[`String`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String
[CSS `<color>`]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

BIN
docs/images/downloader.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

BIN
docs/images/info.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

BIN
docs/images/loading.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

After

Width:  |  Height:  |  Size: 452 KiB

View File

@@ -1,224 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, height=device-height, user-scalable=no, initial-scale=1.0" />
<title>OwnTracks</title>
<link href="static/style.css" rel="stylesheet">
<link href="https://unpkg.com/leaflet@1.4.0/dist/leaflet.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Noto+Sans" rel="stylesheet">
<link href="https://unpkg.com/@mdi/font@3.5.95/css/materialdesignicons.min.css" rel="stylesheet">
</head>
<body>
<div id="app">
<header>
<nav>
<div class="nav-item">
<button
class="button button-outline"
title="Automatically center the map view and zoom in to relevant data"
@click="centerView"
>
Center View
</button>
</div>
<div class="nav-item">
<span class="mdi mdi-24px mdi-layers"></span>
<div class="dropdown">
<button class="dropdown-button button" title="Show/hide layers">
Layer Settings
</button>
<div class="dropdown-body">
<label tabindex="0">
<input type="checkbox" v-model="showLastLocations">
Show last known locations
</label>
<label tabindex="0">
<input type="checkbox" v-model="showLocationHistoryLine">
Show location history (line)
</label>
<label tabindex="0">
<input type="checkbox" v-model="showLocationHistoryPoints">
Show location history (points)
</label>
<label tabindex="0">
<input type="checkbox" v-model="showLocationHeatmap">
Show location heatmap
</label>
</div>
</div>
</div>
<div class="nav-item">
<span class="mdi mdi-24px mdi-calendar-range"></span>
<vuejs-datepicker
v-model="startDate"
:use-utc="true"
:disabled-dates="startDateDisabledDates"
title="Select start date"
></vuejs-datepicker>
to
<vuejs-datepicker
v-model="endDate"
:use-utc="true"
:disabled-dates="endDateDisabledDates"
title="Select end date"
></vuejs-datepicker>
</div>
<div class="nav-item">
<span class="mdi mdi-24px mdi-account"></span>
<select v-model="selectedUser" class="dropdown-button button" title="Select user">
<option value="">
Show All
</option>
<option v-for="user in users" :value="user">
{{ user }}
</option>
</select>
</div>
<div v-if="selectedUser" class="nav-item">
<span class="mdi mdi-24px mdi-cellphone-link"></span>
<select v-model="selectedDevice" class="dropdown-button button" title="Select device">
<option value="">
Show All
</option>
<option v-for="device in devices[selectedUser]" :value="device">
{{ device }}
</option>
</select>
</div>
</nav>
<nav class="nav-shrink">
<div class="nav-item">
<button
class="button button-flat button-icon"
title="Download raw data"
@click="showDownloadModal = !showDownloadModal"
>
<span class="mdi mdi-24px mdi-download"></span>
</button>
</div>
<div class="nav-item">
<button
class="button button-flat button-icon"
title="Information"
@click="showInformationModal = !showInformationModal"
>
<span class="mdi mdi-24px mdi-information-outline"></span>
</button>
</div>
</nav>
</header>
<main>
<l-map ref="map" :zoom="map.zoom" :center="map.center">
<l-tile-layer
:url="map.url"
:attribution="map.attribution"
:options="{maxNativeZoom: map.maxNativeZoom, maxZoom: map.maxZoom}"
></l-tile-layer>
<l-circle
v-if="showLastLocations"
v-for="l in lastLocations"
:key="`${l.topic}-circle`"
:lat-lng="{lat: l.lat, lng: l.lon}"
:radius="l.acc"
:color="map.circle.color"
:fill-color="map.circle.fillColor"
:fill-opacity="map.circle.fillOpacity"
></l-circle>
<l-marker
v-if="showLastLocations"
v-for="l in lastLocations"
:key="`${l.topic}-marker`"
:lat-lng="[l.lat, l.lon]"
>
<location-popup
:user="l.username"
:device="l.device"
:name="l.name"
:face="l.face"
:timestamp="l.tst"
:lat="l.lat"
:lon="l.lon"
:alt="l.alt"
:battery="l.batt"
:speed="l.vel"
></location-popup>
</l-marker>
<l-polyline
v-if="showLocationHistoryLine"
:lat-lngs="locationHistoryLatLngs"
:color="map.polyline.color"
:fill-color="map.polyline.fillColor"
></l-polyline>
<template v-if="showLocationHistoryPoints">
<template v-for="(userDevices, user) in locationHistory">
<template v-for="(deviceLocations, device) in userDevices">
<l-circle-marker
v-for="(l, n) in deviceLocations"
:key="`${user}-${device}-${n}`"
:lat-lng="[l.lat, l.lon]"
:radius="map.circleMarker.radius"
:color="map.circleMarker.color"
:fill-color="map.circleMarker.fillColor"
:fill-opacity="map.circleMarker.fillOpacity"
>
<location-popup
:user="user"
:device="device"
:timestamp="l.tst"
:lat="l.lat"
:lon="l.lon"
:alt="l.alt"
:battery="l.batt"
:speed="l.vel"
></location-popup>
</l-circle-marker>
</template>
</template>
</template>
<template v-if="showLocationHeatmap">
<l-heatmap
v-if="locationHistoryLatLngs.length"
:lat-lng="locationHistoryLatLngs"
:max="map.heatmap.max"
:radius="map.heatmap.radius"
:blur="map.heatmap.blur"
:gradient="map.heatmap.gradient"
></l-heatmap>
</template>
</l-map>
</main>
<modal :visible="showDownloadModal" @close="showDownloadModal = false">
Not implemented.
</modal>
<modal :visible="showInformationModal" @close="showInformationModal = false">
<b>OwnTracks {{ information.ownTracks.version }}</b>
<ul>
<li><a :href="information.ownTracksUi.sourceCodeUrl">OwnTracks UI Source Code</a></li>
<li><a :href="information.ownTracks.documentationUrl">OwnTracks Recorder Documentation</a></li>
<li><a :href="information.ownTracks.sourceCodeUrl">OwnTracks Recorder Source Code</a></li>
<li><a :href="information.ownTracks.twitterUrl">OwnTracks Twitter</a></li>
</ul>
</modal>
</div>
<script src="https://unpkg.com/vue@2.5.22/dist/vue.min.js"></script>
<script src="https://unpkg.com/vuejs-datepicker@1.5.4/dist/vuejs-datepicker.min.js"></script>
<script src="https://unpkg.com/leaflet@1.4.0/dist/leaflet.js"></script>
<script src="https://unpkg.com/vue2-leaflet@1.2.3/dist/vue2-leaflet.min.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script src="https://unpkg.com/deepmerge@3.2.0/dist/umd.js"></script>
<script src="static/components/vue-leaflet-heatmap.js"></script>
<script src="static/components/location-popup.js"></script>
<script src="static/components/modal.js"></script>
<script src="static/config/default.js"></script>
<script src="static/config/custom.js"></script>
<script src="static/main.js"></script>
</body>
</html>

23
jest.config.js Normal file
View File

@@ -0,0 +1,23 @@
module.exports = {
moduleFileExtensions: ["js", "jsx", "json", "vue"],
transform: {
"^.+\\.vue$": "vue-jest",
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$":
"jest-transform-stub",
"^.+\\.jsx?$": "babel-jest",
},
transformIgnorePatterns: ["/node_modules/"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
snapshotSerializers: ["jest-serializer-vue"],
testMatch: [
"**/tests/**/*.test.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)",
],
testURL: "http://localhost/",
watchPlugins: [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname",
],
setupFiles: ["<rootDir>/tests/setup.js"],
};

58
package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "owntracks-ui",
"version": "2.0.0-beta.3",
"author": {
"name": "Linus Groh",
"email": "mail@linusgroh.de"
},
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"cors-proxy": "node scripts/corsProxy.js",
"i18n:report": "vue-cli-service i18n:report --src './src/**/*.?(js|vue)' --locales './src/locales/**/*.json'",
"test": "vue-cli-service test:unit"
},
"dependencies": {
"clipboard-copy": "^3.1.0",
"core-js": "^3.4.8",
"deepmerge": "^4.2.2",
"leaflet": "^1.6.0",
"leaflet.heat": "^0.2.0",
"vue": "^2.6.6",
"vue-feather-icons": "^5.0.0",
"vue-i18n": "^8.0.0",
"vue-js-modal": "^1.3.31",
"vue-outside-events": "^1.1.3",
"vue-router": "^3.1.3",
"vue2-leaflet": "^2.2.1",
"vuejs-datepicker": "^1.6.2",
"vuex": "^3.1.2"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^4.1.1",
"@vue/cli-plugin-eslint": "^4.1.1",
"@vue/cli-plugin-unit-jest": "^4.1.1",
"@vue/cli-service": "^4.1.1",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/test-utils": "1.0.0-beta.30",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0",
"cors-anywhere": "^0.4.1",
"eslint": "^6.7.2",
"eslint-plugin-prettier": "^3.1.1",
"eslint-plugin-vue": "^6.0.1",
"jest-fetch-mock": "^2.1.2",
"lint-staged": "^9.5.0",
"node-sass": "^4.13.0",
"sass-loader": "^8.0.0",
"vue-cli-plugin-i18n": "^0.6.0",
"vue-template-compiler": "^2.5.21"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/owntracks/frontend.git"
}
}

5
postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
};

View File

@@ -0,0 +1,3 @@
// Here you can overwite the default configuration values
window.owntracks = window.owntracks || {};
window.owntracks.config = {};

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

18
public/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>OwnTracks UI</title>
</head>
<body>
<noscript>
<strong>We're sorry but OwnTracks doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<script src="<%= BASE_URL %>config/config.js"></script>
<!-- built files will be auto injected -->
</body>
</html>

25
scripts/corsProxy.js Normal file
View File

@@ -0,0 +1,25 @@
const corsProxy = require("cors-anywhere");
const host = process.env.OT_PROXY_HOST || "0.0.0.0";
const port = process.env.OT_PROXY_PORT || 8888;
const username = process.env.OT_BASIC_AUTH_USERNAME || null;
const password = process.env.OT_BASIC_AUTH_PASSWORD || null;
const options = {
httpProxyOptions: {
ws: true,
},
};
if (username !== null && password !== null) {
console.log(`Basic auth for user ${username} enabled`);
options.setHeaders = {
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString(
"base64"
)}`,
};
}
corsProxy.createServer(options).listen(port, host, () => {
console.log(`Running CORS Anywhere on http://${host}:${port}`);
});

98
src/App.vue Normal file
View File

@@ -0,0 +1,98 @@
<template>
<div id="app">
<AppHeader />
<main>
<router-view />
</main>
<DownloadModal />
<InformationModal />
<LoadingModal />
</div>
</template>
<style lang="scss">
@import "styles/main";
</style>
<script>
import { mapActions } from "vuex";
import config from "@/config";
import * as types from "@/store/mutation-types";
import { log } from "@/logging";
import AppHeader from "@/components/AppHeader";
import DownloadModal from "@/components/modals/Download";
import InformationModal from "@/components/modals/Information";
import LoadingModal from "@/components/modals/Loading";
export default {
components: { AppHeader, DownloadModal, InformationModal, LoadingModal },
created() {
document.documentElement.style.setProperty(
"--color-primary",
config.primaryColor
);
this.populateStateFromQuery(this.$route.query);
this.loadData();
// Update URL query params when relevant values changes
this.$store.subscribe(mutation => {
if (
[
types.SET_SELECTED_USER,
types.SET_SELECTED_DEVICE,
types.SET_START_DATE,
types.SET_END_DATE,
types.SET_MAP_CENTER,
types.SET_MAP_ZOOM,
types.SET_MAP_LAYER_VISIBILITY,
].includes(mutation.type)
) {
this.updateUrlQuery();
}
if (mutation.type === types.SET_IS_LOADING) {
this.$store.state.isLoading
? this.$modal.show("loading")
: this.$modal.hide("loading");
}
});
// Initially update URL query params from state
this.updateUrlQuery();
},
methods: {
...mapActions(["populateStateFromQuery", "loadData"]),
/**
* Update all URL query parameters. This is called whenever any
* of the relevant values change in the Vuex store.
*/
updateUrlQuery() {
const {
map,
startDate: start,
endDate: end,
selectedUser: user,
selectedDevice: device,
} = this.$store.state;
const activeLayers = Object.keys(map.layers).filter(
key => map.layers[key] === true
);
const query = {
lat: map.center.lat,
lng: map.center.lng,
zoom: map.zoom,
start: start.toISOString().split("T")[0],
end: end.toISOString().split("T")[0],
...(user !== null && { user }),
...(user !== null && device !== null && { device }),
...(activeLayers.length > 0 && { layers: activeLayers.join(",") }),
};
log("STATE", "Updating URL query from state");
log(
"STATE",
JSON.parse(JSON.stringify({ map, start, end, user, device }))
);
this.$router.replace({ query }).catch(() => {}); // https://github.com/vuejs/vue-router/issues/2872#issuecomment-519073998
},
},
};
</script>

190
src/api.js Normal file
View File

@@ -0,0 +1,190 @@
import { log, logLevels } from "@/logging";
import { getApiUrl } from "@/util";
/** @typedef {import("./types").QueryParams} QueryParams */
/** @typedef {import("./types").User} User */
/** @typedef {import("./types").Device} Device */
/** @typedef {import("./types").LastLocation} LastLocation */
/** @typedef {import("./types").LocationHistory} LocationHistory */
/**
* Callback for new WebSocket location messages.
*
* @callback webSocketLocationCallback
*/
/**
* Fetch an API resource.
*
* @param {String} path API resource path
* @param {QueryParams} [params] Query parameters
* @return {Promise} Promise returned by the fetch function
*/
const fetchApi = (path, params = {}) => {
const url = getApiUrl(path);
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]));
log("HTTP", `GET ${url.href}`);
return fetch(url).catch(error => log("HTTP", error, logLevels.ERROR));
};
/**
* Get the recorder's version.
*
* @return {String} Version
*/
export const getVersion = async () => {
const response = await fetchApi("/api/0/version");
const json = await response.json();
const version = json.version;
return version;
};
/**
* Get all users.
*
* @return {Array.<User>} Array of usernames
*/
export const getUsers = async () => {
const response = await fetchApi("/api/0/list");
const json = await response.json();
const users = json.results;
return users;
};
/**
* Get all devices for the provided users.
*
* @param {Array.<User>} users Array of usernames
* @return {Object.<User, Array.<Device>>} Object mapping each username to an array of device names
*/
export const getDevices = async users => {
const devices = {};
await Promise.all(
users.map(async user => {
const response = await fetchApi(`/api/0/list`, { user });
const json = await response.json();
const userDevices = json.results;
devices[user] = userDevices;
})
);
return devices;
};
/**
* Get last locations for a specific or all user/device.
*
* @param {User} [user] Get last locations of all devices from this user
* @param {Device} [device] Get last location of specific device
* @return {Array.<LastLocation>} Array of last location objects
*/
export const getLastLocations = async (user, device) => {
const params = {};
if (user) {
params["user"] = user;
if (device) {
params["device"] = device;
}
}
const response = await fetchApi("/api/0/last", params);
const json = await response.json();
return json;
};
/**
* Get the location history of a specific user/device.
*
* @param {User} user Username
* @param {Device} device Device name
* @param {Date} start Start date
* @param {Date} end End date
* @return {LocationHistory} Array of location history objects
*/
export const getUserDeviceLocationHistory = async (
user,
device,
start,
end
) => {
const startDate = start.toISOString().split("T")[0];
const endDate = end.toISOString().split("T")[0];
const response = await fetchApi("/api/0/locations", {
from: `${startDate}T00:00:00`,
to: `${endDate}T23:59:59`,
user,
device,
format: "json",
});
const json = await response.json();
return json.data;
};
/**
* Get the location history of multiple devices.
*
* @param {Object.<User, Array.<Device>>} devices Devices of which the history should be fetched
* @param {Date} start Start date
* @param {Date} end End date
* @return {Object.<User, Object.<Device, LocationHistory>>} Array of location history objects
*/
export const getLocationHistory = async (devices, start, end) => {
const locationHistory = {};
await Promise.all(
Object.keys(devices).map(async user => {
locationHistory[user] = {};
await Promise.all(
devices[user].map(async device => {
locationHistory[user][device] = await getUserDeviceLocationHistory(
user,
device,
start,
end
);
})
);
})
);
return locationHistory;
};
/**
* Connect to the WebSocket API, reconnect when necessary and handle received messages.
*
* @param {webSocketLocationCallback} [callback] Callback for location messages
*/
export const connectWebsocket = async callback => {
let url = getApiUrl("/ws/last");
url.protocol = url.protocol.replace("http", "ws");
url = url.href;
const ws = new WebSocket(url);
log("WS", `Connecting to ${url}`);
ws.onopen = () => {
log("WS", "Connected");
ws.send("LAST");
};
ws.onclose = event => {
log(
"WS",
`Disconnected unexpectedly (reason: ${event.reason ||
"unknown"}). Reconnecting in one second.`,
logLevels.WARNING
);
setTimeout(connectWebsocket, 1000);
};
ws.onmessage = async msg => {
if (msg.data) {
try {
const data = JSON.parse(msg.data);
if (data._type === "location") {
log("WS", "Location update received");
callback && (await callback());
}
} catch (err) {
if (msg.data !== "LAST") {
log("WS", err, logLevels.ERROR);
}
}
} else {
log("WS", "Ping");
}
};
};

View File

@@ -0,0 +1,191 @@
<template>
<header>
<nav>
<div class="nav-item">
<button
class="button button-outline"
:title="
$t('Automatically center the map view and zoom in to relevant data')
"
@click="$root.$emit('fitView')"
>
{{ $t("Fit view") }}
</button>
</div>
<div class="nav-item">
<LayersIcon size="1x" />
<Dropdown :label="$t('Layer settings')" :title="$t('Show/hide layers')">
<label v-for="option in layerSettingsOptions" :key="option.layer">
<input
type="checkbox"
:checked="map.layers[option.layer]"
@change="
setMapLayerVisibility({
layer: option.layer,
visibility: $event.target.checked,
})
"
/>
{{ option.label }}
</label>
</Dropdown>
</div>
<div class="nav-item">
<CalendarIcon size="1x" />
<Datepicker
v-model="startDate"
:use-utc="true"
:disabled-dates="startDateDisabledDates"
:title="$t('Select start date')"
/>
to
<Datepicker
v-model="endDate"
:use-utc="true"
:disabled-dates="endDateDisabledDates"
:title="$t('Select end date')"
/>
</div>
<div class="nav-item">
<UserIcon size="1x" />
<select
v-model="selectedUser"
class="dropdown-button button"
:title="$t('Select user')"
>
<option :value="null">
{{ $t("Show all") }}
</option>
<option v-for="user in users" :value="user" :key="user">
{{ user }}
</option>
</select>
</div>
<div v-if="selectedUser" class="nav-item">
<SmartphoneIcon size="1x" />
<select
v-model="selectedDevice"
class="dropdown-button button"
:title="$t('Select device')"
>
<option :value="null">
{{ $t("Show all") }}
</option>
<option
v-for="device in devices[selectedUser]"
:value="device"
:key="`${selectedUser}-${device}`"
>
{{ device }}
</option>
</select>
</div>
</nav>
<nav class="nav-shrink">
<div class="nav-item">
<button
class="button button-flat button-icon"
:title="$t('Download raw data')"
@click="$modal.show('download')"
>
<DownloadIcon size="1x" />
</button>
</div>
<div class="nav-item">
<button
class="button button-flat button-icon"
:title="$t('Information')"
@click="$modal.show('information')"
>
<InfoIcon size="1x" />
</button>
</div>
</nav>
</header>
</template>
<script>
import { mapActions, mapGetters, mapMutations, mapState } from "vuex";
import {
CalendarIcon,
DownloadIcon,
InfoIcon,
LayersIcon,
SmartphoneIcon,
UserIcon,
} from "vue-feather-icons";
import Datepicker from "vuejs-datepicker";
import Dropdown from "@/components/Dropdown";
import * as types from "@/store/mutation-types";
export default {
components: {
CalendarIcon,
DownloadIcon,
InfoIcon,
LayersIcon,
SmartphoneIcon,
UserIcon,
Datepicker,
Dropdown,
},
data() {
return {
layerSettingsOptions: [
{ layer: "last", label: this.$t("Show last known locations") },
{ layer: "line", label: this.$t("Show location history (line)") },
{ layer: "points", label: this.$t("Show location history (points)") },
{ layer: "heatmap", label: this.$t("Show location heatmap") },
],
};
},
computed: {
...mapState(["users", "devices", "map"]),
...mapGetters(["startDateDisabledDates", "endDateDisabledDates"]),
selectedUser: {
get() {
return this.$store.state.selectedUser;
},
set(value) {
this.setSelectedUser(value);
},
},
selectedDevice: {
get() {
return this.$store.state.selectedDevice;
},
set(value) {
this.setSelectedDevice(value);
},
},
startDate: {
get() {
return this.$store.state.startDate;
},
set(value) {
this.setStartDate(value);
},
},
endDate: {
get() {
return this.$store.state.endDate;
},
set(value) {
this.setEndDate(value);
},
},
},
methods: {
...mapMutations({
setMapLayerVisibility: types.SET_MAP_LAYER_VISIBILITY,
}),
...mapActions([
"setSelectedUser",
"setSelectedDevice",
"setStartDate",
"setEndDate",
]),
},
};
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="dropdown" v-focus-outside="hide" v-click-outside="hide">
<button class="dropdown-button button" :title="title" @click="toggle">
{{ label }}
</button>
<div v-if="active" class="dropdown-body">
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
required: true,
},
title: {
type: String,
default: "",
},
},
data() {
return {
active: false,
};
},
methods: {
toggle() {
this.active = !this.active;
},
hide() {
this.active = false;
},
},
};
</script>

View File

@@ -0,0 +1,134 @@
<template>
<LPopup>
<div v-if="name" class="device">{{ name }}</div>
<div v-else class="device">{{ user }}/{{ device }}</div>
<div class="wrapper">
<img v-if="face" :src="faceImageDataURI" />
<ul class="info-list">
<li>
<ClockIcon size="1x" />
{{ new Date(timestamp * 1000).toLocaleString() }}
</li>
<li>
<MapPinIcon size="1x" />
{{ lat }}
<br />
{{ lon }}
<br />
{{ alt }}m
</li>
<li v-if="address">
<HomeIcon size="1x" />
{{ address }}
</li>
<li v-if="typeof battery === 'number'">
<BatteryIcon size="1x" />
{{ battery }} %
</li>
<li v-if="typeof speed === 'number'">
<ZapIcon size="1x" />
{{ speed }} km/h
</li>
</ul>
</div>
</LPopup>
</template>
<style lang="scss" scoped>
.device {
display: inline-block;
position: relative;
top: -5px;
color: var(--color-primary);
font-weight: bold;
}
.wrapper {
display: flex;
margin-top: 10px;
margin-right: 20px;
img {
align-self: start;
margin-right: 20px;
}
}
</style>
<script>
import {
BatteryIcon,
ClockIcon,
HomeIcon,
MapPinIcon,
ZapIcon,
} from "vue-feather-icons";
import { LPopup } from "vue2-leaflet";
export default {
name: "LDeviceLocationPopup",
components: {
BatteryIcon,
ClockIcon,
HomeIcon,
MapPinIcon,
ZapIcon,
LPopup,
},
props: {
user: {
type: String,
default: "",
},
device: {
type: String,
default: "",
},
name: {
type: String,
default: "",
},
face: {
type: String,
default: null,
},
timestamp: {
type: Number,
default: 0,
},
lat: {
type: Number,
default: 0,
},
lon: {
type: Number,
default: 0,
},
alt: {
type: Number,
default: 0,
},
address: {
type: String,
default: null,
},
battery: {
type: Number,
default: null,
},
speed: {
type: Number,
default: null,
},
},
computed: {
/**
* Return the face image as a data URI string which can be used for an image's src attribute
*
* @return {String} base64-encoded face image data URI
*/
faceImageDataURI() {
return `data:image/png;base64,${this.face}`;
},
},
};
</script>

133
src/components/LHeatmap.vue Normal file
View File

@@ -0,0 +1,133 @@
<template>
<div />
</template>
<style scoped>
div {
display: none;
}
</style>
<script>
// See https://github.com/KoRiGaN/Vue2Leaflet/blob/e0cf0f29bc519f0a70f0f1eb6e579f947e7ea4ce/src/utils/utils.js
// to understand the `custom` attribute of each prop, how the `set<Prop>`
// methods are being used and why `mapObject` has to be named `mapObject`.
import { findRealParent, propsBinder } from "vue2-leaflet";
import L, { DomEvent } from "leaflet";
import "leaflet.heat";
const props = {
latLng: {
type: Array,
default: () => [],
custom: false,
},
minOpacity: {
type: Number,
custom: true,
default: 0.05,
},
maxZoom: {
type: Number,
custom: true,
default: 18,
},
radius: {
type: Number,
custom: true,
default: 25,
},
blur: {
type: Number,
custom: true,
default: 15,
},
max: {
type: Number,
custom: true,
default: 1.0,
},
gradient: {
type: Object,
custom: true,
default: null,
},
visible: {
type: Boolean,
custom: true,
default: true,
},
};
export default {
props,
mounted() {
const options = {};
if (this.minOpacity) {
options.minOpacity = this.minOpacity;
}
if (this.maxZoom) {
options.maxZoom = this.maxZoom;
}
if (this.radius) {
options.radius = this.radius;
}
if (this.blur) {
options.blur = this.blur;
}
if (this.max) {
options.max = this.max;
}
if (this.gradient) {
options.gradient = this.gradient;
}
this.mapObject = L.heatLayer(this.latLng, options);
DomEvent.on(this.mapObject, this.$listeners);
propsBinder(this, this.mapObject, props);
this.parentContainer = findRealParent(this.$parent);
this.parentContainer.addLayer(this, !this.visible);
this.$watch(
"latLng",
newVal => {
this.mapObject.setLatLngs(newVal);
},
{ deep: true }
);
},
beforeDestroy() {
this.parentContainer.removeLayer(this);
},
methods: {
setMinOpacity(minOpacity) {
this.mapObject.setOptions({ minOpacity });
},
setMaxZoom(maxZoom) {
this.mapObject.setOptions({ maxZoom });
},
setRadius(radius) {
this.mapObject.setOptions({ radius });
},
setBlur(blur) {
this.mapObject.setOptions({ blur });
},
setMax(max) {
this.mapObject.setOptions({ max });
},
setGradient(gradient) {
this.mapObject.setOptions({ gradient });
},
setVisible(newVal, oldVal) {
if (newVal === oldVal) return;
if (newVal) {
this.parentContainer.addLayer(this);
} else {
this.parentContainer.removeLayer(this);
}
},
addLatLng(value) {
this.mapObject.addLatLng(value);
},
},
};
</script>

View File

@@ -0,0 +1,102 @@
<template>
<modal name="download" adaptive>
<pre class="data"><code>{{ data }}</code></pre>
<div class="options">
<input
v-model="options.minifyJson"
type="checkbox"
id="option-minify-json"
/>
<label for="option-minify-json">
{{ $t("Minify JSON") }}
</label>
</div>
<div class="buttons">
<button class="button button-outline button-primary" @click="copy">
{{ $t("Copy to clipboard") }}
</button>
<button class="button button-primary" @click="download">
{{ $t("Download") }}
</button>
</div>
</modal>
</template>
<style lang="scss" scoped>
.data {
max-height: 300px;
}
.options {
margin-top: 30px;
}
.buttons {
display: flex;
margin-top: 30px;
button {
flex: 1;
&:first-child {
margin-right: 10px;
}
&:last-child {
margin-left: 10px;
}
}
}
</style>
<script>
import { mapState } from "vuex";
import copy from "clipboard-copy";
import { download } from "@/util";
export default {
data() {
return {
options: {
minifyJson: false,
},
};
},
computed: {
...mapState([
"startDate",
"endDate",
"selectedUser",
"selectedDevice",
"locationHistory",
]),
data() {
return this.locationHistory;
},
},
methods: {
copy() {
const data = JSON.stringify(
this.data,
null,
this.options.minifyJson ? 0 : 2
);
copy(data);
},
download() {
const data = JSON.stringify(
this.data,
null,
this.options.minifyJson ? 0 : 2
);
const start = this.startDate.toISOString().split("T")[0];
const end = this.endDate.toISOString().split("T")[0];
const user = this.selectedUser ? `_${this.selectedUser}` : "";
const device = this.selectedDevice ? `_${this.selectedDevice}` : "";
const filename = `data_${start}_${end}${user}${device}.json`;
download(data, filename, "application/json");
},
},
};
</script>

View File

@@ -0,0 +1,55 @@
<template>
<modal name="information" adaptive>
<ul class="info-list">
<li>
<GithubIcon size="1x" />
<a href="https://github.com/owntracks/frontend">
owntracks/frontend
</a>
({{ frontendVersion }})
</li>
<li>
<GithubIcon size="1x" />
<a href="https://github.com/owntracks/recorder">
owntracks/recorder
</a>
({{ recorderVersion || "Loading version..." }})
</li>
<li>
<GlobeIcon size="1x" />
<a href="https://owntracks.org">
{{ $t("OwnTracks website") }}
</a>
</li>
<li>
<BookIcon size="1x" />
<a href="https://owntracks.org/booklet/">
{{ $t("OwnTracks documentation") }}
</a>
</li>
<li>
<TwitterIcon size="1x" />
<a href="https://twitter.com/OwnTracks">
{{ $t("OwnTracks on Twitter") }}
</a>
</li>
</ul>
</modal>
</template>
<script>
import { mapState } from "vuex";
import {
BookIcon,
GithubIcon,
GlobeIcon,
TwitterIcon,
} from "vue-feather-icons";
export default {
components: { BookIcon, GithubIcon, GlobeIcon, TwitterIcon },
computed: {
...mapState(["frontendVersion", "recorderVersion"]),
},
};
</script>

View File

@@ -0,0 +1,32 @@
<template>
<modal name="loading" :click-to-close="false" adaptive>
<LoaderIcon class="loader" size="1.5x" />
{{ $t("Loading data, please wait...") }}
</modal>
</template>
<style scoped>
.loader {
animation: spinning 2s linear infinite;
margin-right: 5px;
}
@keyframes spinning {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
<script>
import { LoaderIcon } from "vue-feather-icons";
export default {
components: {
LoaderIcon,
},
};
</script>

84
src/config.js Normal file
View File

@@ -0,0 +1,84 @@
import deepmerge from "deepmerge";
const endDate = new Date();
endDate.setUTCHours(0, 0, 0, 0);
const startDate = new Date(endDate);
startDate.setUTCMonth(startDate.getMonth() - 1);
const DEFAULT_CONFIG = {
api: {
baseUrl: `${window.location.protocol}//${window.location.host}`,
},
endDate,
ignorePingLocation: true,
locale: "en",
map: {
attribution:
'&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
center: {
lat: 0,
lng: 0,
},
circle: {
color: null,
fillColor: null,
fillOpacity: 0.2,
},
circleMarker: {
color: null,
fillColor: "#fff",
fillOpacity: 1,
radius: 4,
},
controls: {
scale: {
display: true,
imperial: true,
maxWidth: 200,
metric: true,
position: "bottomleft",
},
zoom: {
display: true,
position: "topleft",
},
},
heatmap: {
blur: 15,
gradient: null,
max: 20,
radius: 25,
},
layers: {
heatmap: false,
last: true,
line: true,
points: false,
},
maxNativeZoom: 19,
maxPointDistance: null,
maxZoom: 21,
polyline: {
color: null,
fillColor: "transparent",
},
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
zoom: 19,
},
onLocationChange: {
reloadHistory: false,
},
primaryColor: "#3f51b5",
selectedDevice: null,
selectedUser: null,
startDate,
verbose: false,
};
// Use deepmerge to combine the default and user-defined configuration.
// This enables the user to use a fairly small config object which only
// needs to contain actual changes, not all default values - and these
// stay up-to-date automatically.
// There might not be a user-defined config, default to an empty object.
export default deepmerge(DEFAULT_CONFIG, (window.owntracks || {}).config || {});

9
src/constants.js Normal file
View File

@@ -0,0 +1,9 @@
// Regular expression for an ISO 8601 YYYY-MM-DD date.
// Used to validate dates from URL query parameters.
export const ISO_DATE_REGEXP = new RegExp(
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
);
// https://en.wikipedia.org/wiki/Earth_radius
// Used to calculate the distance between two coordinates.
export const EARTH_RADIUS_IN_KM = 6371;

23
src/i18n.js Normal file
View File

@@ -0,0 +1,23 @@
import Vue from "vue";
import VueI18n from "vue-i18n";
import config from "@/config";
Vue.use(VueI18n);
const locales = require.context("./locales", true, /[A-Za-z0-9-_,\s]+\.json$/i);
const messages = {};
locales.keys().forEach(key => {
const matched = key.match(/([A-Za-z0-9-_]+)\./i);
if (matched && matched.length > 1) {
const locale = matched[1];
messages[locale] = locales(key);
}
});
export default new VueI18n({
locale: config.locale,
fallbackLocale: "en",
formatFallbackMessages: true,
messages,
});

24
src/locales/de.json Normal file
View File

@@ -0,0 +1,24 @@
{
"Automatically center the map view and zoom in to relevant data": "Kartenansicht automatisch zentrieren und zu relevanten Daten hereinzoomen",
"Fit view": "Ansicht anpassen",
"Layer settings": "Ebeneneinstellungen",
"Show/hide layers": "Ebenen ein-/ausblenden",
"Select start date": "Startdatum auswählen",
"Select end date": "Enddatum auswählen",
"Select user": "Benutzer auswählen",
"Show all": "Alle anzeigen",
"Select device": "Gerät auswählen",
"Download raw data": "Rohdaten herunterladen",
"Information": "Information",
"Show last known locations": "Zeige letzte bekannte Standorte",
"Show location history (line)": "Zeige Standortverlauf (Linie)",
"Show location history (points)": "Zeige Standortverlauf (Punkte)",
"Show location heatmap": "Zeige Standort-Heatmap",
"Minify JSON": "JSON minimieren",
"Copy to clipboard": "In die Zwischenablage kopieren",
"Download": "Herunterladen",
"OwnTracks website": "OwnTracks Webseite",
"OwnTracks documentation": "OwnTracks Dokumentation",
"OwnTracks on Twitter": "OwnTracks auf Twitter",
"Loading data, please wait...": "Daten werden geladen, bitte warten..."
}

24
src/locales/en.json Normal file
View File

@@ -0,0 +1,24 @@
{
"Automatically center the map view and zoom in to relevant data": "Automatically center the map view and zoom in to relevant data",
"Fit view": "Fit view",
"Layer settings": "Layer settings",
"Show/hide layers": "Show/hide layers",
"Select start date": "Select start date",
"Select end date": "Select end date",
"Select user": "Select user",
"Show all": "Show all",
"Select device": "Select device",
"Download raw data": "Download raw data",
"Information": "Information",
"Show last known locations": "Show last known locations",
"Show location history (line)": "Show location history (line)",
"Show location history (points)": "Show location history (points)",
"Show location heatmap": "Show location heatmap",
"Minify JSON": "Minify JSON",
"Copy to clipboard": "Copy to clipboard",
"Download": "Download",
"OwnTracks website": "OwnTracks website",
"OwnTracks documentation": "OwnTracks documentation",
"OwnTracks on Twitter": "OwnTracks on Twitter",
"Loading data, please wait...": "Loading data, please wait..."
}

39
src/logging.js Normal file
View File

@@ -0,0 +1,39 @@
import config from "@/config";
export const logLevels = {
INFO: "INFO",
WARNING: "WARNING",
ERROR: "ERROR",
};
/* eslint-disable no-console */
const logFunctions = {
[logLevels.INFO]: console.info,
[logLevels.WARNING]: console.warn,
[logLevels.ERROR]: console.error,
};
/* eslint-enable no-console */
const logColors = {
[logLevels.INFO]: "#0d66ba",
[logLevels.WARNING]: "#cf8429",
[logLevels.ERROR]: "#ad1515",
};
export const log = (label, message, level = logLevels.INFO) => {
if (!Object.keys(logLevels).includes(level)) {
log("WARNING", `invalid log level: ${level}`, logLevels.WARNING);
return;
}
if (level !== logLevels.ERROR && !config.verbose) {
return;
}
const css = `
background: ${logColors[level]};
border-radius: 5px;
color: #fff;
padding: 3px;
`;
const logFunc = logFunctions[level];
logFunc(`%c${label}`, css, message);
};

19
src/main.js Normal file
View File

@@ -0,0 +1,19 @@
import Vue from "vue";
import App from "@/App.vue";
import i18n from "@/i18n";
import router from "@/router";
import store from "@/store";
import VModal from "vue-js-modal";
import VOutsideEvents from "vue-outside-events";
Vue.use(VModal);
Vue.use(VOutsideEvents);
Vue.config.productionTip = false;
new Vue({
i18n,
router,
store,
render: h => h(App),
}).$mount("#app");

17
src/router.js Normal file
View File

@@ -0,0 +1,17 @@
import Vue from "vue";
import Router from "vue-router";
import Map from "./views/Map.vue";
Vue.use(Router);
export default new Router({
mode: "history",
base: process.env.BASE_URL,
routes: [
{
path: "/",
name: "map",
component: Map,
},
],
});

208
src/store/actions.js Normal file
View File

@@ -0,0 +1,208 @@
import * as types from "@/store/mutation-types";
import * as api from "@/api";
import config from "@/config";
import { isIsoDate } from "@/util";
/** @typedef {import("./types").QueryParams} QueryParams */
/** @typedef {import("./types").User} User */
/** @typedef {import("./types").Device} Device */
/**
* Populate the state from URL query parameters.
*
* @param {QueryParams} query URL query parameters
*/
const populateStateFromQuery = ({ state, commit }, query) => {
if (query.lat && !isNaN(parseFloat(query.lat))) {
commit(types.SET_MAP_CENTER, {
lat: query.lat,
lng: parseFloat(state.map.center.lng),
});
}
if (query.lng && !isNaN(parseFloat(query.lng))) {
commit(types.SET_MAP_CENTER, {
lat: parseFloat(state.map.center.lat),
lng: query.lng,
});
}
if (query.zoom && !isNaN(parseInt(query.zoom))) {
commit(types.SET_MAP_ZOOM, parseInt(query.zoom));
}
if (query.start && isIsoDate(query.start)) {
commit(types.SET_START_DATE, new Date(query.start));
}
if (query.end && isIsoDate(query.end)) {
commit(types.SET_END_DATE, new Date(query.end));
}
if (query.user) {
commit(types.SET_SELECTED_USER, query.user);
}
if (query.device) {
commit(types.SET_SELECTED_DEVICE, query.device);
}
if (query.layers) {
const activeLayers = query.layers.split(",");
Object.keys(state.map.layers).forEach(layer => {
const visibility = activeLayers.includes(layer);
if (state.map.layers[layer] !== visibility) {
commit(types.SET_MAP_LAYER_VISIBILITY, { layer, visibility });
}
});
}
};
/**
* Trigger loading of all required data: users, devices, last locations,
* location history, version and initiate WebSocket connection.
*/
const loadData = async ({ dispatch }) => {
await dispatch("getUsers");
await dispatch("getDevices");
await dispatch("getLastLocations");
await dispatch("getLocationHistory");
await dispatch("getRecorderVersion");
await dispatch("connectWebsocket");
};
/**
* Reload last locations and location history. Will be called when
* start date, end date, selected user or selected device changes.
*/
const reloadData = async ({ dispatch }) => {
await dispatch("getLastLocations");
await dispatch("getLocationHistory");
};
/**
* Connect to WebSocket to receive live location updates. When an update is
* received, reload last locations and location history depending on config.
*/
const connectWebsocket = async ({ dispatch }) => {
api.connectWebsocket(async () => {
// TODO: keep cards from HTTP API response in the Vuex store so we
// can use the data from the WebSocket location update (which does
// not contain card information) and don't have to poll the API.
await dispatch("getLastLocations");
if (config.onLocationChange.reloadHistory) {
await dispatch("getLocationHistory");
}
});
};
/**
* Load user names.
*/
const getUsers = async ({ commit }) => {
commit(types.SET_USERS, await api.getUsers());
};
/**
* Load devices names of all users.
*/
const getDevices = async ({ commit, state }) => {
commit(types.SET_DEVICES, await api.getDevices(state.users));
};
/**
* Load last location of the selected user/device.
*/
const getLastLocations = async ({ commit, state }) => {
let lastLocations = await api.getLastLocations(
state.selectedUser,
state.selectedDevice
);
if (config.ignorePingLocation) {
// Remove ping/ping from the owntracks/recorder Docker image
// https://github.com/owntracks/frontend/issues/12
lastLocations = lastLocations.filter(
l => !(l.username === "ping" && l.device === "ping")
);
}
commit(types.SET_LAST_LOCATIONS, lastLocations);
};
/**
* Load location history of all devices, in the selected date range.
*/
const getLocationHistory = async ({ commit, state }) => {
commit(types.SET_IS_LOADING, true);
let devices;
if (state.selectedUser) {
if (state.selectedDevice) {
devices = { [state.selectedUser]: [state.selectedDevice] };
} else {
devices = { [state.selectedUser]: state.devices[state.selectedUser] };
}
} else {
devices = state.devices;
}
commit(
types.SET_LOCATION_HISTORY,
await api.getLocationHistory(devices, state.startDate, state.endDate)
);
commit(types.SET_IS_LOADING, false);
};
/**
* Load the OwnTracks recorder version.
*/
const getRecorderVersion = async ({ commit }) => {
commit(types.SET_RECORDER_VERSION, await api.getVersion());
};
/**
* Set the selected user and reload the location history.
*
* @param {User} user Name of the new selected user
*/
const setSelectedUser = async ({ commit, dispatch }, user) => {
commit(types.SET_SELECTED_DEVICE, null);
commit(types.SET_SELECTED_USER, user);
await dispatch("reloadData");
};
/**
* Set the selected device and reload the location history.
*
* @param {Device} device Name of the new selected device
*/
const setSelectedDevice = async ({ commit, dispatch }, device) => {
commit(types.SET_SELECTED_DEVICE, device);
await dispatch("reloadData");
};
/**
* Set the start date for loading data and reload the location history.
*
* @param {Date} startDate Start date for loading data
*/
const setStartDate = async ({ commit, dispatch }, startDate) => {
commit(types.SET_START_DATE, startDate);
await dispatch("reloadData");
};
/**
* Set the end date for loading data and reload the location history.
*
* @param {Date} endDate End date for loading data
*/
const setEndDate = async ({ commit, dispatch }, endDate) => {
commit(types.SET_END_DATE, endDate);
await dispatch("reloadData");
};
export default {
populateStateFromQuery,
loadData,
reloadData,
connectWebsocket,
getUsers,
getDevices,
getLastLocations,
getLocationHistory,
getRecorderVersion,
setSelectedUser,
setSelectedDevice,
setStartDate,
setEndDate,
};

101
src/store/getters.js Normal file
View File

@@ -0,0 +1,101 @@
import L from "leaflet";
import config from "@/config";
import { distanceBetweenCoordinates } from "@/util";
/** @typedef {import("./types").State} State */
/** @typedef {import("./types").MultiLocationHistory} MultiLocationHistory */
/** @typedef {import("./types").DatepickerConfig} DatepickerConfig */
/**
* From the selected users' and devices' location histories, create an
* array of all coordinates.
*
* @param {State} state
* @param {MultiLocationHistory} state.locationHistory Location history of selected users and devices
* @return {Array.<L.LatLng>} All coordinates
*/
const locationHistoryLatLngs = state => {
const latLngs = [];
Object.keys(state.locationHistory).forEach(user => {
Object.keys(state.locationHistory[user]).forEach(device => {
state.locationHistory[user][device].forEach(coordinate => {
latLngs.push(L.latLng(coordinate.lat, coordinate.lon));
});
});
});
return latLngs;
};
/**
* From the selected users' and devices' location histories, create an
* array of coordinate groups where the distance between two subsequent
* coordinates does not exceed `config.map.maxPointDistance`.
*
* @param {State} state
* @param {MultiLocationHistory} state.locationHistory Location history of selected users and devices
* @return {Array.<Array.<L.LatLng>>} Groups of coherent coordinates
*/
const locationHistoryLatLngGroups = state => {
const groups = [];
Object.keys(state.locationHistory).forEach(user => {
Object.keys(state.locationHistory[user]).forEach(device => {
let latLngs = [];
state.locationHistory[user][device].forEach(coordinate => {
const latLng = L.latLng(coordinate.lat, coordinate.lon);
// Skip if group splitting is disabled or this is the first
// coordinate in the current group
if (config.map.maxPointDistance !== null && latLngs.length > 0) {
const lastLatLng = latLngs.slice(-1)[0];
if (
distanceBetweenCoordinates(lastLatLng, latLng) >
config.map.maxPointDistance
) {
// Distance is too far, start new group of coordinate
groups.push(latLngs);
latLngs = [];
}
}
// Add coordinate to current active group
latLngs.push(latLng);
});
groups.push(latLngs);
});
});
return groups;
};
/**
* For the start date selector, disable all dates above the end date
* or current date.
*
* @param {State} state
* @param {Date} state.endDate End date
* @return {DatepickerConfig} Configuration for the `disabled-dates` prop of the `vuejs-datepicker` component
*/
const startDateDisabledDates = state => {
return {
customPredictor: date => date > state.endDate || date > new Date(),
};
};
/**
* For the end date selector, disable all dates below the start date
* or above the current date.
*
* @param {State} state
* @param {Date} state.startDate Start date
* @return {DatepickerConfig} Configuration for the `disabled-dates` prop of the `vuejs-datepicker` component
*/
const endDateDisabledDates = state => {
return {
customPredictor: date => date < state.startDate || date > new Date(),
};
};
export default {
locationHistoryLatLngs,
locationHistoryLatLngGroups,
startDateDisabledDates,
endDateDisabledDates,
};

33
src/store/index.js Normal file
View File

@@ -0,0 +1,33 @@
import Vue from "vue";
import Vuex from "vuex";
import getters from "@/store/getters";
import mutations from "@/store/mutations";
import actions from "@/store/actions";
import config from "@/config";
Vue.use(Vuex);
export default new Vuex.Store({
state: {
isLoading: false,
frontendVersion: process.env.PACKAGE_VERSION,
recorderVersion: "",
users: [],
devices: {},
lastLocations: [],
locationHistory: {},
selectedUser: config.selectedUser,
selectedDevice: config.selectedUser !== null ? config.selectedDevice : null,
startDate: config.startDate,
endDate: config.endDate,
map: {
center: config.map.center,
zoom: config.map.zoom,
layers: config.map.layers,
},
},
getters,
mutations,
actions,
});

View File

@@ -0,0 +1,13 @@
export const SET_IS_LOADING = "SET_IS_LOADING";
export const SET_RECORDER_VERSION = "SET_RECORDER_VERSION";
export const SET_USERS = "SET_USERS";
export const SET_DEVICES = "SET_DEVICES";
export const SET_LAST_LOCATIONS = "SET_LAST_LOCATIONS";
export const SET_LOCATION_HISTORY = "SET_LOCATION_HISTORY";
export const SET_SELECTED_USER = "SET_SELECTED_USER";
export const SET_SELECTED_DEVICE = "SET_SELECTED_DEVICE";
export const SET_START_DATE = "SET_START_DATE";
export const SET_END_DATE = "SET_END_DATE";
export const SET_MAP_CENTER = "SET_MAP_CENTER";
export const SET_MAP_ZOOM = "SET_MAP_ZOOM";
export const SET_MAP_LAYER_VISIBILITY = "SET_MAP_LAYER_VISIBILITY";

43
src/store/mutations.js Normal file
View File

@@ -0,0 +1,43 @@
import * as types from "@/store/mutation-types";
export default {
[types.SET_IS_LOADING](state, isLoading) {
state.isLoading = isLoading;
},
[types.SET_RECORDER_VERSION](state, version) {
state.recorderVersion = version;
},
[types.SET_USERS](state, users) {
state.users = users;
},
[types.SET_DEVICES](state, devices) {
state.devices = devices;
},
[types.SET_LAST_LOCATIONS](state, lastLocations) {
state.lastLocations = lastLocations;
},
[types.SET_LOCATION_HISTORY](state, locationHistory) {
state.locationHistory = locationHistory;
},
[types.SET_SELECTED_USER](state, selectedUser) {
state.selectedUser = selectedUser;
},
[types.SET_SELECTED_DEVICE](state, selectedDevice) {
state.selectedDevice = selectedDevice;
},
[types.SET_START_DATE](state, startDate) {
state.startDate = startDate;
},
[types.SET_END_DATE](state, endDate) {
state.endDate = endDate;
},
[types.SET_MAP_CENTER](state, center) {
state.map.center = center;
},
[types.SET_MAP_ZOOM](state, zoom) {
state.map.zoom = zoom;
},
[types.SET_MAP_LAYER_VISIBILITY](state, { layer, visibility }) {
state.map.layers[layer] = visibility;
},
};

281
src/styles/_base.scss Normal file
View File

@@ -0,0 +1,281 @@
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
:root {
--color-text: #333;
--color-background: #fff;
--color-primary: #3f51b5;
--color-primary-text: #fff;
--drop-shadow: drop-shadow(0 10px 10px rgb(0, 0, 0, 0.2));
--dropdown-arrow: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2225%22%20height%3D%2210%22%3E%3Cpath%20fill%3D%22%23333%22%20fill-opacity%3D%221%22%20stroke%3D%22none%22%20d%3D%22M0%2C0%20L0%2C0%20L1%2C0%20L1%2C6%20L7%2C6%20L7%2C7%20L0%2C7%20z%22%20transform%3D%22rotate(-45%205%200)%22%20%2F%3E%3C%2Fsvg%3E");
}
html, body {
height: 100%;
}
body {
font-family: "Noto Sans", sans-serif;
font-size: 14px;
color: var(--color-text);
}
a {
color: var(--color-primary);
}
ul {
list-style: inside;
}
input[type="checkbox"] {
appearance: none;
border: 0; // Remove the unchecked checkbox outline in Safari on iOS
border-radius: 4px; // Round the focus box-shadow
cursor: pointer;
margin-right: 3px;
position: relative;
vertical-align: top;
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.2);
}
&::before {
border: 2px solid var(--color-primary);
border-radius: 4px;
content: "";
display: block;
height: 16px;
width: 16px;
}
&:checked::before {
background: var(--color-primary);
}
&:checked::after {
border-bottom: 2px solid var(--color-primary-text);
border-right: 2px solid var(--color-primary-text);
content: "";
display: inline-block;
height: 10px;
left: 7px;
position: absolute;
top: 3px;
transform: rotate(45deg);
width: 4px;
}
& + label {
cursor: pointer;
}
}
pre {
background: #000;
border-radius: 3px;
color: #ddd;
display: block;
font-family: Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console",
"Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono",
"Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, monospace;
overflow-x: auto;
code {
display: block;
margin: 20px;
}
}
#app {
display: flex;
min-height: 100%;
flex-direction: column;
// Only select immediate child as the datepicker contains a <header> as well
> header {
display: flex;
padding: 20px;
white-space: nowrap;
overflow-x: auto;
color: var(--color-primary-text);
background: var(--color-primary);
nav {
display: flex;
flex: 1;
&:not(:first-child) {
margin-left: 20px;
}
&.nav-shrink {
flex: 0 1 auto;
}
.nav-item:not(:first-child) {
margin-left: 20px;
}
.feather {
font-size: 20px;
margin-right: 10px;
}
.button-icon .feather {
margin: 0;
}
}
}
main {
flex: 1;
position: relative;
}
ul.info-list {
list-style: none;
li {
// https://stackoverflow.com/a/17158366/5952681
margin-left: 25px;
text-indent: -25px;
.feather {
font-size: 16px;
margin-right: 8px;
}
a {
font-weight: bold;
text-decoration: none;
}
& + li {
margin-top: 15px;
}
}
}
}
.vdp-datepicker input {
cursor: pointer;
color: var(--color-text);
background: var(--color-background);
border: 0;
border-radius: 18px;
padding: 8px 16px;
min-width: 130px;
}
.button {
cursor: pointer;
color: var(--color-text);
background: var(--color-background);
border: 0;
border-radius: 18px;
overflow: hidden;
padding: 8px 16px;
text-overflow: ellipsis;
white-space: nowrap;
&.button-primary {
color: var(--color-primary-text);
background: var(--color-primary);
&:focus::-moz-focus-inner {
border-color: var(--color-primary-text)e;
}
}
&.button-outline {
border: 1px solid var(--color-background);
color: var(--color-primary-text);
background: transparent;
&.button-primary {
border-color: var(--color-primary);
color: var(--color-text);
}
}
&.button-flat {
color: var(--color-primary-text);
background: transparent;
&.button-primary {
color: var(--color-text);
}
}
&.button-outline,
&.button-flat {
transition: background 0.2s;
&:hover,
&:focus {
background: rgba(0, 0, 0, 0.2);
}
}
&.button-icon {
padding: 8px;
}
}
.dropdown {
display: inline-block;
}
.dropdown-button,
.vdp-datepicker input {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: var(--dropdown-arrow);
background-repeat: no-repeat;
background-position-x: right;
background-position-y: center;
padding-right: 30px;
}
.dropdown-body {
position: absolute;
margin-top: 12px;
padding: 8px 0;
border-radius: 3px;
color: var(--color-text);
background: var(--color-background);
filter: var(--drop-shadow);
z-index: 1;
label {
cursor: pointer;
display: block;
padding: 8px 15px;
&:hover {
background: rgba(0, 0, 0, 0.2);
}
}
}
.dropdown-body::before,
.vdp-datepicker .vdp-datepicker__calendar::before {
content: "";
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid transparent;
border-bottom: 10px solid var(--color-background);
position: absolute;
top: -20px;
left: 20px;
}
.feather {
vertical-align: middle;
}

View File

@@ -0,0 +1,32 @@
.vdp-datepicker {
position: static !important;
display: inline-block;
white-space: initial;
overflow: initial;
input {
width: 120px;
}
.vdp-datepicker__calendar {
color: var(--color-text);
border: 0;
border-radius: 3px;
margin-top: 12px;
filter: var(--drop-shadow);
.cell {
&:not(.blank):not(.disabled).day:hover,
&:not(.blank):not(.disabled).month:hover,
&:not(.blank):not(.disabled).year:hover {
border-color: var(--color-primary);
}
&.selected,
&.selected:hover {
background: var(--color-primary);
color: var(--color-primary-text);
}
}
}
}

38
src/styles/_map.scss Normal file
View File

@@ -0,0 +1,38 @@
.leaflet-container {
position: absolute;
z-index: 0;
.leaflet-popup {
filter: var(--drop-shadow);
margin-bottom: 25px;
.leaflet-popup-content-wrapper {
border-radius: 3px;
box-shadow: none;
.leaflet-popup-content {
margin: 30px;
}
}
a.leaflet-popup-close-button {
color: inherit;
width: auto;
height: auto;
padding: 10px;
margin-top: 15px;
margin-right: 15px;
border-radius: 100px;
&:hover,
&:focus {
background: rgba(0, 0, 0, 0.2);
color: inherit;
}
}
}
.leaflet-popup-tip {
box-shadow: none;
}
}

22
src/styles/_modal.scss Normal file
View File

@@ -0,0 +1,22 @@
.v--modal-overlay {
background: rgba(0, 0, 0, 0.5) !important;
.v--modal-background-click {
display: flex;
justify-content: center;
align-items: center;
.v--modal-box.v--modal {
top: initial !important;
left: initial !important;
width: auto !important;
height: auto !important;
max-width: 95vw;
max-height: 95vh;
overflow: auto;
padding: 30px;
border-radius: 3px;
background: var(--color-background);
}
}
}

4
src/styles/main.scss Normal file
View File

@@ -0,0 +1,4 @@
@import "base";
@import "datepicker";
@import "map";
@import "modal";

55
src/types.js Normal file
View File

@@ -0,0 +1,55 @@
/**
* A coordinate with latitude and longitude.
*
* @typedef Coordinate
* @type {(Object|L.LatLng)}
* @property {Number} lat Latitude
* @property {Number} lng Longitude
*/
/**
* Vuex state.
*
* @typedef {Object.<String, *>} State
*/
/**
* URL query parameter object.
*
* @typedef {Object.<String, *>} QueryParams
*/
/**
* @typedef {Object} DatepickerConfig
* @property {Function} DatepickerConfig.customPredictor Custom predictor function
*/
/**
* A user's name.
*
* @typedef {String} User
*/
/**
* A device's name.
*
* @typedef {String} Device
*/
/**
* A last location object.
*
* @typedef {Object.<String, *>} LastLocation
*/
/**
* An array of location history objects
*
* @typedef {Array.<Object.<String, *>>} LocationHistory
*/
/**
* Multiple arrays of location history objects mapped to user and devices.
*
* @typedef {Object.<User, Object.<Device, LocationHistory>>} MultiLocationHistory
*/

83
src/util.js Normal file
View File

@@ -0,0 +1,83 @@
import config from "@/config";
import { ISO_DATE_REGEXP, EARTH_RADIUS_IN_KM } from "@/constants";
/** @typedef {import("./types").Coordinate} Coordinate */
/**
* Get a complete URL for any API resource, taking the
* base URL configuration into account.
*
* @param {String} path Path to the API resource
* @return {URL} Final API URL
*/
export const getApiUrl = path => {
const normalizedBaseUrl = config.api.baseUrl.endsWith("/")
? config.api.baseUrl.slice(1)
: config.api.baseUrl;
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return new URL(`${normalizedBaseUrl}${normalizedPath}`);
};
/**
* Check if the given string is an ISO 8601 YYYY-MM-DD date.
*
* @param {String} s Input value to be tested
* @return {Boolean} Whether the input is an ISO 8601 date
*/
export const isIsoDate = s => ISO_DATE_REGEXP.test(s);
/**
* Convert degrees to radians.
*
* @param {Number} degrees Angle in degrees
* @return {Number} Angle in radians
*/
export const degreesToRadians = degrees => (degrees * Math.PI) / 180;
/**
* Calculate the distance between two coordinates. Uses the haversine formula,
* which is not 100% accurate - but that's not the goal here.
*
* https://en.wikipedia.org/wiki/Haversine_formula
*
* @param {Coordinate} c1 First coordinate
* @param {Coordinate} c2 Second coordinate
* @return {Number} Distance in meters
*/
export const distanceBetweenCoordinates = (c1, c2) => {
const r = EARTH_RADIUS_IN_KM * 1000;
const phi1 = degreesToRadians(c1.lat);
const phi2 = degreesToRadians(c2.lat);
const lambda1 = degreesToRadians(c1.lng);
const lambda2 = degreesToRadians(c2.lng);
const d =
2 *
r *
Math.asin(
Math.sqrt(
Math.sin((phi2 - phi1) / 2) ** 2 +
Math.cos(phi1) *
Math.cos(phi2) *
Math.sin((lambda2 - lambda1) / 2) ** 2
)
);
return d;
};
/**
* Let the user download a string as file.
*
* @param {String} text Content of the file
* @param {String} filename Suggested filename for the browser
* @param {String} [mimeType] Content mime type
*/
export const download = (text, filename, mimeType = "text/plain") => {
const dataUrl = `data:${mimeType},${encodeURIComponent(text)}`;
const element = document.createElement("a");
element.href = dataUrl;
element.download = filename;
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};

207
src/views/Map.vue Normal file
View File

@@ -0,0 +1,207 @@
<template>
<LMap
ref="map"
:center="map.center"
:zoom="map.zoom"
:options="{ zoomControl: false }"
@update:center="setMapCenter"
@update:zoom="setMapZoom"
>
<LControlZoom
v-if="controls.zoom.display"
:position="controls.zoom.position"
/>
<LControlScale
v-if="controls.scale.display"
:position="controls.scale.position"
:maxWidth="controls.scale.maxWidth"
:metric="controls.scale.metric"
:imperial="controls.scale.imperial"
/>
<LTileLayer
:url="url"
:attribution="attribution"
:options="{ maxNativeZoom, maxZoom }"
/>
<template v-if="map.layers.last">
<LCircle
v-for="l in lastLocations"
:key="`${l.topic}-circle`"
:lat-lng="[l.lat, l.lon]"
:radius="l.acc"
v-bind="circle"
/>
<LMarker
v-for="l in lastLocations"
:key="`${l.topic}-marker`"
:lat-lng="[l.lat, l.lon]"
>
<LDeviceLocationPopup
:user="l.username"
:device="l.device"
:name="l.name"
:face="l.face"
:timestamp="l.tst"
:lat="l.lat"
:lon="l.lon"
:alt="l.alt"
:battery="l.batt"
:speed="l.vel"
/>
</LMarker>
</template>
<template v-if="map.layers.line">
<LPolyline
v-for="(group, i) in locationHistoryLatLngGroups"
:key="i"
:lat-lngs="group"
v-bind="polyline"
/>
</template>
<template v-if="map.layers.points">
<template v-for="(userDevices, user) in locationHistory">
<template v-for="(deviceLocations, device) in userDevices">
<LCircleMarker
v-for="(l, n) in deviceLocations"
:key="`${user}-${device}-${n}`"
:lat-lng="[l.lat, l.lon]"
v-bind="circleMarker"
>
<LDeviceLocationPopup
:user="user"
:device="device"
:timestamp="l.tst"
:lat="l.lat"
:lon="l.lon"
:alt="l.alt"
:battery="l.batt"
:speed="l.vel"
></LDeviceLocationPopup>
</LCircleMarker>
</template>
</template>
</template>
<template v-if="map.layers.heatmap">
<LHeatmap
v-if="locationHistoryLatLngs.length"
:lat-lng="locationHistoryLatLngs"
:max="heatmap.max"
:radius="heatmap.radius"
:blur="heatmap.blur"
:gradient="heatmap.gradient"
/>
</template>
</LMap>
</template>
<script>
import { mapGetters, mapState, mapMutations } from "vuex";
import L from "leaflet";
import {
LMap,
LTileLayer,
LControlScale,
LControlZoom,
LMarker,
LCircleMarker,
LCircle,
LPolyline,
} from "vue2-leaflet";
import "leaflet/dist/leaflet.css";
import markerIcon from "leaflet/dist/images/marker-icon.png";
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
import markerShadow from "leaflet/dist/images/marker-shadow.png";
import * as types from "@/store/mutation-types";
import config from "@/config";
import LHeatmap from "@/components/LHeatmap";
import LDeviceLocationPopup from "@/components/LDeviceLocationPopup";
// See https://github.com/KoRiGaN/Vue2Leaflet/issues/28#issuecomment-299038157
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl: markerIcon,
iconRetinaUrl: markerIcon2x,
shadowUrl: markerShadow,
});
export default {
components: {
LMap,
LTileLayer,
LControlScale,
LControlZoom,
LMarker,
LCircleMarker,
LCircle,
LPolyline,
LDeviceLocationPopup,
LHeatmap,
},
data() {
return {
attribution: config.map.attribution,
center: this.$store.state.map.center,
controls: config.map.controls,
heatmap: config.map.heatmap,
maxZoom: config.map.maxZoom,
maxNativeZoom: config.map.maxNativeZoom,
url: config.map.url,
zoom: this.$store.state.map.zoom,
circle: {
...config.map.circle,
color: config.map.circle.color || config.primaryColor,
fillColor: config.map.circle.fillColor || config.primaryColor,
},
circleMarker: {
...config.map.circleMarker,
color: config.map.circleMarker.color || config.primaryColor,
},
polyline: {
...config.map.polyline,
color: config.map.polyline.color || config.primaryColor,
},
};
},
mounted() {
this.$root.$on("fitView", () => {
this.fitView();
});
},
computed: {
...mapGetters(["locationHistoryLatLngs", "locationHistoryLatLngGroups"]),
...mapState(["lastLocations", "locationHistory", "map"]),
},
methods: {
...mapMutations({
setMapCenter: types.SET_MAP_CENTER,
setMapZoom: types.SET_MAP_ZOOM,
}),
/**
* Fit all objects on the map into view.
*/
fitView() {
if (
(this.map.layers.line ||
this.map.layers.points ||
this.map.layers.heatmap) &&
this.locationHistoryLatLngs.length > 0
) {
this.$refs.map.mapObject.fitBounds(
new L.LatLngBounds(this.locationHistoryLatLngs)
);
} else if (this.map.layers.last && this.lastLocations.length > 0) {
const locations = this.lastLocations.map(l => L.latLng(l.lat, l.lon));
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(locations), {
maxZoom: this.maxNativeZoom,
});
}
},
},
};
</script>

View File

@@ -1,80 +0,0 @@
(() => {
const props = {
user: {
type: String,
default: '',
},
device: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
face: {
type: String,
default: null,
},
timestamp: {
type: Number,
default: 0,
},
lat: {
type: Number,
default: 0,
},
lon: {
type: Number,
default: 0,
},
alt: {
type: Number,
default: 0,
},
address: {
type: String,
default: null,
},
battery: {
type: Number,
default: null,
},
speed: {
type: Number,
default: null,
},
};
const { LPopup } = Vue2Leaflet;
Vue.component('location-popup', {
template: `
<l-popup>
<img v-if="face" class="location-popup-face" :src="faceImageDataURI">
<b v-if="name">{{ name }}</b>
<b v-else>{{ user }}/{{ device }}</b>
<div class="location-popup-detail">
<span class="mdi mdi-16px mdi-calendar-clock"></span> {{ new Date(timestamp * 1000).toLocaleString() }}
</div>
<div class="location-popup-detail">
<span class="mdi mdi-16px mdi-crosshairs-gps"></span> {{ lat }}, {{ lon }}, {{ alt }}m
</div class="location-popup-detail">
<div v-if="address" class="location-popup-detail">
<span class="mdi mdi-16px mdi-map-marker"></span> {{ address }}
</div>
<div v-if="typeof battery === 'number'" class="location-popup-detail">
<span class="mdi mdi-16px mdi-battery"></span> {{ battery }} %
</div>
<div v-if="typeof battery === 'number'" class="location-popup-detail">
<span class="mdi mdi-16px mdi-speedometer"></span> {{ speed }} km/h
</div>
</l-popup>
`,
components: { LPopup },
props,
computed: {
faceImageDataURI() {
return `data:image/png;base64,${this.face}`;
},
},
});
})();

View File

@@ -1,18 +0,0 @@
Vue.component('modal', {
template: `
<div class="modal" v-show="visible" @click.self="$emit('close')">
<div class="modal-container">
<button class="modal-close-button" title="Close" @click="$emit('close')">
&times;
</button>
<slot></slot>
</div>
</div>
`,
props: {
visible: {
type: Boolean,
default: false,
},
},
});

View File

@@ -1,152 +0,0 @@
(() => {
const capitalizeFirstLetter = (string) => {
return string.charAt(0).toUpperCase() + string.slice(1);
}
const propsBinder = (vueElement, leafletElement, props) => {
for (const key in props) {
const setMethodName = 'set' + capitalizeFirstLetter(key);
const deepValue = (props[key].type === Object) ||
(props[key].type === Array) ||
(Array.isArray(props[key].type));
if (props[key].custom && vueElement[setMethodName]) {
vueElement.$watch(key, (newVal, oldVal) => {
vueElement[setMethodName](newVal, oldVal);
}, {
deep: deepValue
});
} else if (setMethodName === 'setOptions') {
vueElement.$watch(key, (newVal, oldVal) => {
L.setOptions(leafletElement, newVal);
}, {
deep: deepValue
});
} else if (leafletElement[setMethodName]) {
vueElement.$watch(key, (newVal, oldVal) => {
leafletElement[setMethodName](newVal);
}, {
deep: deepValue
});
}
}
};
const { findRealParent, L } = Vue2Leaflet;
const props = {
latLng: {
type: Array,
custom: false,
default: () => []
},
minOpacity: {
type: Number,
custom: true,
default: 0.05
},
maxZoom: {
type: Number,
custom: true,
default: 18
},
radius: {
type: Number,
custom: true,
default: 25
},
blur: {
type: Number,
custom: true,
default: 15
},
max: {
type: Number,
custom: true,
default: 1.0
},
gradient: {
type: Object,
custom: true,
default: () => ({
0.4: 'blue',
0.6: 'cyan',
0.7: 'lime',
0.8: 'yellow',
1.0: 'red'
})
},
visible: {
type: Boolean,
custom: true,
default: true
}
};
Vue.component('l-heatmap', {
props,
template: '<div></div>',
mounted() {
const options = {};
if (this.minOpacity) {
options.minOpacity = this.minOpacity;
}
if (this.maxZoom) {
options.maxZoom = this.maxZoom;
}
if (this.radius) {
options.radius = this.radius;
}
if (this.blur) {
options.blur = this.blur;
}
if (this.max) {
options.max = this.max;
}
if (this.gradient) {
options.gradient = this.gradient;
}
this.mapObject = L.heatLayer(this.latLng, options);
L.DomEvent.on(this.mapObject, this.$listeners);
propsBinder(this, this.mapObject, props);
this.$watch('latLng', (newVal, _) => {
this.mapObject.setLatLngs(newVal);
}, { deep: true });
this.parentContainer = findRealParent(this.$parent);
this.parentContainer.addLayer(this, !this.visible);
},
beforeDestroy() {
this.parentContainer.removeLayer(this);
},
methods: {
setMinOpacity(newVal) {
this.mapObject.setOptions({ minOpacity: newVal });
},
setMaxZoom(newVal) {
this.mapObject.setOptions({ maxZoom: newVal });
},
setRadius(newVal) {
this.mapObject.setOptions({ radius: newVal });
},
setBlur(newVal) {
this.mapObject.setOptions({ blur: newVal });
},
setMax(newVal) {
this.mapObject.setOptions({ max: newVal });
},
setGradient(newVal) {
this.mapObject.setOptions({ gradient: newVal });
},
setVisible(newVal, oldVal) {
if (newVal === oldVal) return;
if (newVal) {
this.parentContainer.addLayer(this);
} else {
this.parentContainer.removeLayer(this);
}
},
addLatLng(value) {
this.mapObject.addLatLng(value);
}
}
});
})();

View File

@@ -1,2 +0,0 @@
// Here you can overwite values from default.js
window.config = {};

View File

@@ -1,27 +0,0 @@
(() => {
const endDate = new Date();
endDate.setUTCHours(0);
endDate.setUTCMinutes(0);
endDate.setUTCSeconds(0);
const startDate = new Date(endDate);
startDate.setUTCMonth(startDate.getMonth()-1);
window.defaultConfig = {
accentColor: '#3388ff',
startDate,
endDate,
map: {
center: L.latLng(0, 0),
zoom: 19,
maxNativeZoom: 19,
maxZoom: 21,
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
heatmap: {
max: 20,
radius: 25,
blur: 15,
gradient: null, // https://github.com/mourner/simpleheat/blob/gh-pages/simpleheat.js#L22
},
},
};
})();

View File

@@ -1,218 +0,0 @@
(() => {
const { LMap, LTileLayer, LMarker, LCircleMarker, LCircle, LPolyline } = Vue2Leaflet;
const config = deepmerge(window.defaultConfig, window.config);
new Vue({
el: '#app',
components: { vuejsDatepicker, LMap, LTileLayer, LMarker, LCircleMarker, LPolyline, LCircle },
data: {
users: [],
devices: {},
lastLocations: [],
locationHistory: {},
showLastLocations: true,
showLocationHistoryPoints: false,
showLocationHistoryLine: false,
showLocationHeatmap: false,
selectedUser: '',
selectedDevice: '',
startDate: config.startDate,
endDate: config.endDate,
showDownloadModal: false,
showInformationModal: false,
map: {
center: config.map.center,
zoom: config.map.zoom,
maxNativeZoom: config.map.maxNativeZoom,
maxZoom: config.map.maxZoom,
url: config.map.url,
attribution: config.map.attribution,
polyline: {
color: config.accentColor,
fillColor: 'transparent',
},
circle: {
color: config.accentColor,
fillColor: config.accentColor,
fillOpacity: 0.2,
},
circleMarker: {
radius: 4,
color: config.accentColor,
fillColor: '#fff',
fillOpacity: 1,
},
heatmap: {
max: config.map.heatmap.max,
radius: config.map.heatmap.radius,
blur: config.map.heatmap.radius,
gradient: config.map.heatmap.gradient,
},
},
information: {
ownTracks: {
version: '',
documentationUrl: 'https://owntracks.org/booklet/',
sourceCodeUrl: 'https://github.com/owntracks/recorder',
twitterUrl: 'https://twitter.com/OwnTracks',
},
ownTracksUi: {
sourceCodeUrl: 'https://github.com/linusg/owntracks-ui',
},
}
},
watch: {
selectedUser: async function () {
this.selectedDevice = '';
this.lastLocations = await this.getLastLocations();
this.locationHistory = await this.getLocationHistory();
},
selectedDevice: async function () {
this.lastLocations = await this.getLastLocations();
this.locationHistory = await this.getLocationHistory();
},
startDate: async function () {
this.locationHistory = await this.getLocationHistory();
},
endDate: async function () {
this.locationHistory = await this.getLocationHistory();
},
},
computed: {
locationHistoryLatLngs() {
const latLngs = [];
Object.keys(this.locationHistory).forEach((user) => {
Object.keys(this.locationHistory[user]).forEach((device) => {
this.locationHistory[user][device].forEach((l) => {
latLngs.push(L.latLng(l.lat, l.lon));
});
});
});
return latLngs;
},
startDateDisabledDates() {
return {
customPredictor: (date) => (date > this.endDate) || (date > new Date())
};
},
endDateDisabledDates() {
return {
customPredictor: (date) => (date < this.startDate) || (date > new Date())
};
},
},
methods: {
init: async function () {
const root = document.documentElement;
root.style.setProperty('--color-accent', config.accentColor);
this.users = await this.getUsers();
this.devices = await this.getDevices();
this.lastLocations = await this.getLastLocations();
this.locationHistory = await this.getLocationHistory();
this.centerView();
await this.connectWebsocket();
this.information.ownTracks.version = await this.getVersion();
},
connectWebsocket: async function () {
const wsUrl = `${document.location.protocol.replace('http', 'ws')}//${document.location.host}/ws/last`;
const ws = new WebSocket(wsUrl);
console.log(`[WS] Connecting to ${wsUrl}...`);
ws.onopen = (e) => {
console.log('[WS] Connected');
ws.send('LAST');
};
ws.onclose = () => {
console.log('[WS] Disconnected. Reconnecting in one second...')
setTimeout(this.connectWebsocket, 1000);
};
ws.onmessage = async (msg) => {
if (msg.data) {
try {
const data = JSON.parse(msg.data);
if (data._type === 'location') {
console.log('[WS] Location update received');
this.lastLocations = await this.getLastLocations();
this.locationHistory = await this.getLocationHistory();
}
} catch (err) {}
} else {
console.log('[WS] Ping');
}
};
},
getVersion: async function () {
const response = await fetch('/api/0/version');
const json = await response.json();
const version = json.version;
return version;
},
getUsers: async function () {
const response = await fetch('/api/0/list');
const json = await response.json();
const users = json.results;
return users;
},
getDevices: async function () {
const devices = {};
await Promise.all(this.users.map(async (user) => {
const response = await fetch(`/api/0/list?user=${user}`);
const json = await response.json();
const userDevices = json.results;
devices[user] = userDevices;
}));
return devices;
},
getLastLocations: async function () {
let url = '/api/0/last';
if (this.selectedUser !== '') {
url += `?&user=${this.selectedUser}`;
if (this.selectedDevice !== '') {
url += `&device=${this.selectedDevice}`;
}
}
const response = await fetch(url);
const json = await response.json();
return json;
},
getLocationHistory: async function () {
let users;
let devices;
if (this.selectedUser === '') {
users = this.users;
devices = { ...this.devices };
} else {
users = [this.selectedUser];
if (this.selectedDevice === '') {
devices = { [this.selectedUser]: this.devices[this.selectedUser] };
} else {
devices = { [this.selectedUser]: [this.selectedDevice] };
}
}
const locations = {};
await Promise.all(users.map(async (user) => {
locations[user] = {};
await Promise.all(devices[user].map(async (device) => {
const startDateString = `${this.startDate.toISOString().split('T')[0]}T00:00:00`;
const endDateString = `${this.endDate.toISOString().split('T')[0]}T23:59:59`;
const url = `/api/0/locations?from=${startDateString}&to=${endDateString}&format=json&user=${user}&device=${device}`;
const response = await fetch(url);
const json = await response.json();
const userDeviceLocations = json.data;
locations[user][device] = userDeviceLocations;
}));
}));
return locations;
},
centerView() {
if ((this.showLocationHistoryPoints || this.showLocationHistoryLine || this.showLocationHeatmap) && this.locationHistoryLatLngs.length > 0) {
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(this.locationHistoryLatLngs));
} else if (this.showLastLocations && this.lastLocations.length > 0) {
const locations = this.lastLocations.map((l) => L.latLng(l.lat, l.lon));
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(locations), {maxZoom: this.map.maxNativeZoom});
}
},
},
mounted() {
this.init();
},
});
})();

View File

@@ -1,270 +0,0 @@
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
:root {
--color-text: #333;
--color-background: #fff;
--color-accent: #3388ff;
--color-accent-text: #fff;
--drop-shadow: drop-shadow(0 10px 10px rgb(0, 0, 0, 0.2));
--dropdown-arrow: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2225%22%20height%3D%2210%22%3E%3Cpath%20fill%3D%22%23333%22%20fill-opacity%3D%221%22%20stroke%3D%22none%22%20d%3D%22M0%2C0%20L0%2C0%20L1%2C0%20L1%2C6%20L7%2C6%20L7%2C7%20L0%2C7%20z%22%20transform%3D%22rotate(-45%205%200)%22%20%2F%3E%3C%2Fsvg%3E");
}
html, body {
height: 100%;
}
body {
font-family: "Noto Sans", sans-serif;
font-size: 13px;
color: var(--color-text);
}
a {
color: var(--color-accent);
}
ul {
list-style: inside;
}
#app {
display: flex;
min-height: 100%;
flex-direction: column;
}
#app > header {
display: flex;
padding: 20px;
white-space: nowrap;
overflow-x: auto;
color: var(--color-accent-text);
background: var(--color-accent);
}
#app > header > nav {
display: flex;
flex: 1;
}
#app > header > nav:not(:first-child) {
margin-left: 20px;
}
#app > header > nav.nav-shrink {
flex: 0 1 auto;
}
#app > header > nav .nav-item {
display: inline-block;
}
#app > header > nav .nav-item:not(:first-child) {
margin-left: 20px;
}
#app > main {
flex: 1;
/* https://github.com/linusg/owntracks-ui/issues/6 */
display: flex;
}
.button,
.vdp-datepicker input {
cursor: pointer;
color: var(--color-text);
background: var(--color-background);
border: 0;
border-radius: 18px;
padding: 8px 16px;
}
.button-outline {
border: 1px solid var(--color-background);
color: var(--color-accent-text);
background: transparent;
}
.button-flat {
color: var(--color-accent-text);
background: transparent;
}
.button-icon {
padding: 8px 0;
}
.dropdown {
display: inline-block;
}
.dropdown-button,
.vdp-datepicker input {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: var(--dropdown-arrow);
background-repeat: no-repeat;
background-position-x: right;
background-position-y: center;
padding-right: 30px;
}
.dropdown-body {
display: none;
position: absolute;
margin-top: 12px;
padding: 8px 0;
border-radius: 3px;
color: var(--color-text);
background: var(--color-background);
filter: var(--drop-shadow);
z-index: 2000;
}
.dropdown-body::before,
.vdp-datepicker .vdp-datepicker__calendar::before {
content: "";
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid transparent;
border-bottom: 10px solid var(--color-background);
position: absolute;
top: -20px;
left: 20px;
}
.dropdown:focus-within .dropdown-body {
display: block;
}
.dropdown-body label {
cursor: pointer;
display: block;
padding: 8px 15px;
}
.dropdown-body label:hover {
background: rgba(0, 0, 0, 0.1);
}
.dropdown-body label input[type=checkbox] {
position: relative;
top: 2px;
}
.modal {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.4);
filter: var(--drop-shadow);
z-index: 4000;
}
.modal .modal-container {
min-width: 300px;
padding: 20px;
border-radius: 3px;
background: var(--color-background);
}
.modal .modal-container .modal-close-button {
display: block;
border: none;
float: right;
font-size: 24px;
line-height: 16px;
background: transparent;
cursor: pointer;
}
.location-popup-face {
border-radius: 50%;
border: 2px solid var(--color-background);
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
}
.location-popup-detail {
white-space: nowrap;
}
.leaflet-container {
/* https://github.com/linusg/owntracks-ui/issues/6 */
height: auto !important;
}
.leaflet-container .leaflet-popup {
filter: var(--drop-shadow);
}
.leaflet-container .leaflet-popup .leaflet-popup-content-wrapper {
border-radius: 3px;
box-shadow: none;
}
.leaflet-container .leaflet-popup a.leaflet-popup-close-button {
padding: 5px 5px 0 0;
}
.leaflet-popup-tip-container .leaflet-popup-tip {
box-shadow: none;
}
.vdp-datepicker {
position: static !important;
display: inline-block;
white-space: initial;
overflow: initial;
z-index: 3000;
}
.vdp-datepicker input {
width: 120px;
}
.vdp-datepicker .vdp-datepicker__calendar {
color: var(--color-text);
border: 0;
border-radius: 3px;
z-index: 4000;
margin-top: 12px;
filter: var(--drop-shadow);
}
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).day:hover,
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).month:hover,
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).year:hover {
border-color: var(--color-accent);
}
.vdp-datepicker .vdp-datepicker__calendar .cell.selected,
.vdp-datepicker .vdp-datepicker__calendar .cell.selected:hover {
background: var(--color-accent);
color: var(--color-accent-text);
}
header .mdi {
position: relative;
top: 5px;
margin-right: 3px;
}
header .button .mdi {
line-height: 0;
}
.mdi-16px.mdi-set,
.mdi-16px.mdi::before {
font-size: 16px;
}

5
tests/.eslintrc.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
env: {
jest: true,
},
};

219
tests/api.test.js Normal file
View File

@@ -0,0 +1,219 @@
import * as api from "@/api";
describe("API", () => {
beforeEach(() => {
fetch.resetMocks();
});
test("getVersion", async () => {
fetch.mockResponse(JSON.stringify({ version: "1.2.3" }));
const version = await api.getVersion();
expect(version).toBe("1.2.3");
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0].href).toEqual(
"http://localhost/api/0/version"
);
});
test("getUsers", async () => {
fetch.mockResponse(JSON.stringify({ results: ["foo", "bar"] }));
const users = await api.getUsers();
expect(users).toEqual(["foo", "bar"]);
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0].href).toEqual("http://localhost/api/0/list");
});
test("getDevices", async () => {
fetch.mockResponses(
[JSON.stringify({ results: ["phone", "tablet"] })],
[JSON.stringify({ results: ["laptop"] })]
);
const devices = await api.getDevices(["foo", "bar"]);
expect(devices).toEqual({ foo: ["phone", "tablet"], bar: ["laptop"] });
expect(fetch.mock.calls.length).toEqual(2);
expect(fetch.mock.calls[0][0].href).toEqual(
"http://localhost/api/0/list?user=foo"
);
expect(fetch.mock.calls[1][0].href).toEqual(
"http://localhost/api/0/list?user=bar"
);
});
test("getLastLocations", async () => {
const response = [
{
_type: "location",
tid: "pp",
lat: 51.47879,
lon: -0.010677,
tst: 0,
_http: true,
topic: "owntracks/ping/ping",
username: "ping",
device: "ping",
ghash: "gcpuzg2",
isotst: "1970-01-01T00:00:00Z",
disptst: "1970-01-01 00:00:00",
},
];
fetch.mockResponse(JSON.stringify(response));
const lastLocation = await api.getLastLocations();
expect(lastLocation).toEqual(response);
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0].href).toEqual("http://localhost/api/0/last");
});
test("getLastLocations with user", async () => {
const response = [
// Other properties not relevant for testing
{
username: "foo",
device: "phone",
},
{
username: "foo",
device: "tablet",
},
];
fetch.mockResponse(JSON.stringify(response));
const lastLocation = await api.getLastLocations("foo");
expect(lastLocation).toEqual(response);
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0].href).toEqual(
"http://localhost/api/0/last?user=foo"
);
});
test("getLastLocations with user and device", async () => {
const response = [
// Other properties not relevant for testing
{
username: "foo",
device: "phone",
},
];
fetch.mockResponse(JSON.stringify(response));
const lastLocation = await api.getLastLocations("foo", "phone");
expect(lastLocation).toEqual(response);
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0].href).toEqual(
"http://localhost/api/0/last?user=foo&device=phone"
);
});
test("getUserDeviceLocationHistory", async () => {
const response = {
count: 1,
data: [
{
batt: 100,
lon: -0.010677,
acc: 20,
bs: 1,
vac: 10,
topic: "owntracks/foo/phone",
lat: 51.47879,
conn: "w",
tst: 1568841029,
alt: 31,
_type: "location",
tid: "AD",
_http: true,
ghash: "gcpv4k9",
isorcv: "2019-09-18T21:10:29Z",
isotst: "2019-09-18T21:10:29Z",
disptst: "2019-09-18 21:10:29",
},
],
status: 200,
};
fetch.mockResponse(JSON.stringify(response));
const locationHistory = await api.getUserDeviceLocationHistory(
"foo",
"phone",
new Date(Date.UTC(1970, 0, 1)),
new Date(Date.UTC(1970, 11, 31))
);
expect(locationHistory).toEqual(response.data);
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0].href).toEqual(
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=phone&format=json"
);
});
test("getLocationHistory", async () => {
fetch.mockResponses(
[
JSON.stringify({
count: 1,
data: [
{
topic: "owntracks/foo/phone",
},
],
status: 200,
}),
],
[
JSON.stringify({
count: 1,
data: [
{
topic: "owntracks/foo/tablet",
},
],
status: 200,
}),
],
[
JSON.stringify({
count: 1,
data: [
{
topic: "owntracks/bar/laptop",
},
],
status: 200,
}),
]
);
const locationHistory = await api.getLocationHistory(
{ foo: ["phone", "tablet"], bar: ["laptop"] },
new Date(Date.UTC(1970, 0, 1)),
new Date(Date.UTC(1970, 11, 31))
);
expect(locationHistory).toEqual({
foo: {
phone: [{ topic: "owntracks/foo/phone" }],
tablet: [{ topic: "owntracks/foo/tablet" }],
},
bar: { laptop: [{ topic: "owntracks/bar/laptop" }] },
});
expect(fetch.mock.calls.length).toEqual(3);
expect(fetch.mock.calls[0][0].href).toEqual(
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=phone&format=json"
);
expect(fetch.mock.calls[1][0].href).toEqual(
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=tablet&format=json"
);
expect(fetch.mock.calls[2][0].href).toEqual(
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=bar&device=laptop&format=json"
);
});
});

1
tests/setup.js Normal file
View File

@@ -0,0 +1 @@
global.fetch = require("jest-fetch-mock");

94
tests/util.test.js Normal file
View File

@@ -0,0 +1,94 @@
import config from "@/config";
import {
getApiUrl,
isIsoDate,
degreesToRadians,
distanceBetweenCoordinates,
} from "@/util";
describe("getApiUrl", () => {
test("without base URL", () => {
// See testURL in jest.config.js
expect(getApiUrl("foo").href).toBe("http://localhost/foo");
expect(getApiUrl("/foo").href).toBe("http://localhost/foo");
expect(getApiUrl("/foo/bar").href).toBe("http://localhost/foo/bar");
});
test("with base URL", () => {
config.api.baseUrl = "http://example.com/owntracks";
expect(getApiUrl("foo").href).toBe("http://example.com/owntracks/foo");
expect(getApiUrl("/foo").href).toBe("http://example.com/owntracks/foo");
expect(getApiUrl("/foo/bar").href).toBe(
"http://example.com/owntracks/foo/bar"
);
});
});
describe("isIsoDate", () => {
test("no match", () => {
expect(isIsoDate("foo")).toBe(false);
expect(isIsoDate("2019")).toBe(false);
expect(isIsoDate("2019-09")).toBe(false);
expect(isIsoDate("2019.09.27")).toBe(false);
expect(isIsoDate("2019_09_27")).toBe(false);
expect(isIsoDate("2019/09/27")).toBe(false);
expect(isIsoDate("27-09-2019")).toBe(false);
expect(isIsoDate("27.09.2019")).toBe(false);
expect(isIsoDate("27_09_2019")).toBe(false);
expect(isIsoDate("27/09/2019")).toBe(false);
expect(isIsoDate("0000-00-00")).toBe(false);
expect(isIsoDate("1234-56-78")).toBe(false);
});
test("match", () => {
expect(isIsoDate("0000-01-01")).toBe(true);
expect(isIsoDate("2019-09-27")).toBe(true);
expect(isIsoDate("9999-12-31")).toBe(true);
});
});
describe("degreesToRadians", () => {
test("expected results", () => {
expect(degreesToRadians(0)).toBe(0);
expect(degreesToRadians(45)).toBe(0.7853981633974483);
expect(degreesToRadians(90)).toBe(1.5707963267948966);
expect(degreesToRadians(180)).toBe(3.141592653589793);
expect(degreesToRadians(360)).toBe(6.283185307179586);
expect(degreesToRadians(-180)).toBe(-3.141592653589793);
});
});
describe("distanceBetweenCoordinates", () => {
test("expected results", () => {
expect(
distanceBetweenCoordinates({ lat: 0, lng: 0 }, { lat: 0, lng: 0 })
).toBe(0);
// The Shard - Victoria Memorial
expect(
distanceBetweenCoordinates(
{ lat: 51.5046678, lng: -0.0870769 },
{ lat: 51.501752, lng: -0.1408258 }
)
// 3.74km according to Google Maps
).toBe(3734.3632679046705);
// Gatwick Airport - Heathrow Airport
expect(
distanceBetweenCoordinates(
{ lat: 51.1526929, lng: -0.1752475 },
{ lat: 51.4720694, lng: -0.4499871 }
)
// 40km according to Google Maps
).toBe(40321.457586930104);
// Berlin - San Francisco
expect(
distanceBetweenCoordinates(
{ lat: 52.5067614, lng: 13.284651 },
{ lat: 37.7576948, lng: -122.4726193 }
)
// 9,102.73km according to Google Maps
).toBe(9105627.810109457);
});
});

25
vue.config.js Normal file
View File

@@ -0,0 +1,25 @@
const fs = require("fs");
const webpack = require("webpack");
const packageJson = fs.readFileSync("./package.json");
const version = JSON.parse(packageJson).version;
module.exports = {
configureWebpack: {
plugins: [
new webpack.DefinePlugin({
"process.env": {
PACKAGE_VERSION: `"${version}"`,
},
}),
],
},
pluginOptions: {
i18n: {
locale: "en",
fallbackLocale: "en",
localeDir: "locales",
},
},
};

11283
yarn.lock Normal file

File diff suppressed because it is too large Load Diff