Compare commits
52 Commits
31bd07f82c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
1982a45392
|
|||
|
3cfe42023b
|
|||
|
e043f475da
|
|||
|
aef2a1ae60
|
|||
|
215e848efd
|
|||
|
01089aaf12
|
|||
|
9c0aa29bff
|
|||
|
404a995e4f
|
|||
|
062330bcaa
|
|||
|
39a461df56
|
|||
|
711fbfd821
|
|||
|
3a872bceb2
|
|||
|
4ee4869f82
|
|||
|
618f00a09a
|
|||
|
b8e704c300
|
|||
|
e521edd62b
|
|||
|
e7a3a0a673
|
|||
|
c70a419160
|
|||
|
cb6568ea46
|
|||
|
73241c4116
|
|||
| 4e26bbd6f2 | |||
| a5acdc53a1 | |||
| 09a4bcb201 | |||
| 5c58abd977 | |||
| 2241b3c2d0 | |||
| ca8f962102 | |||
| 8c5e359f5b | |||
| 5bb7df157c | |||
| db5fec8616 | |||
| c05a4fba79 | |||
| dda00b4ece | |||
| 82e354f381 | |||
| 76eba15dd6 | |||
| 847c79fcfb | |||
| d368097a8d | |||
| e8a11ecd95 | |||
| 4140799e50 | |||
| 2b28478323 | |||
| d35e60a32d | |||
| 7ba6a2d429 | |||
| 79d4af32ac | |||
| 7f53165704 | |||
| cd87a41940 | |||
| 34b1e2ad20 | |||
| fade25b71d | |||
| 6aed013796 | |||
| 02a07bf76a | |||
| b753feaaeb | |||
| d3c0aba10a | |||
| 97fa628968 | |||
| b9a599fc97 | |||
| 1a23dd6ca0 |
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@ -0,0 +1,36 @@
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
bash python3 py3-pip nginx uwsgi uwsgi-python3 certbot certbot-nginx npm jq
|
||||
|
||||
RUN npm install -g web-push
|
||||
|
||||
|
||||
|
||||
WORKDIR /root/ftracker
|
||||
|
||||
COPY ftracker/ ./ftracker/
|
||||
COPY setup.py .
|
||||
COPY README.md .
|
||||
COPY LICENSE.md .
|
||||
COPY res/config.deploy.ini /etc/ftracker/config.ini
|
||||
|
||||
RUN pip3 install wheel
|
||||
RUN pip3 install .
|
||||
|
||||
|
||||
|
||||
COPY web/ /var/www/html/ftracker/
|
||||
COPY res/ ./res/
|
||||
|
||||
COPY res/ftracker.nossl.nginx.conf /etc/nginx/conf.d/ftracker.conf
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
RUN mkdir -p /etc/ftracker /var/ftracker \
|
||||
&& chown -R nginx:nginx /etc/ftracker /var/ftracker
|
||||
|
||||
|
||||
|
||||
STOPSIGNAL SIGINT
|
||||
RUN chmod +x ./res/docker-entrypoint.sh
|
||||
ENTRYPOINT [ "./res/docker-entrypoint.sh" ]
|
||||
200
INSTALL.md
Normal file
200
INSTALL.md
Normal file
@ -0,0 +1,200 @@
|
||||
How to install and set up an `ftracker` instance
|
||||
================================================
|
||||
|
||||
## Installation
|
||||
|
||||
There are 2 methods: Docker and Manual
|
||||
|
||||
Docker is usually easier and faster but might not suit your needs.
|
||||
|
||||
### Method A: Docker
|
||||
|
||||
Pull the container from docker hub using
|
||||
|
||||
```bash
|
||||
sudo docker pull fasttube/ftracker
|
||||
```
|
||||
|
||||
OR build the container locally with
|
||||
|
||||
```bash
|
||||
sudo docker build . -t fasttube/ftracker
|
||||
```
|
||||
|
||||
Then, if you want the container to also handle SSL so it can run standalone you
|
||||
need to pass it a domain and email so it can obtain a certificate from `Let's
|
||||
encrypt`. Use the first path in the `-v` option to point to your config file
|
||||
(see below for customization options):
|
||||
|
||||
```bash
|
||||
sudo docker run \
|
||||
-it --rm \
|
||||
--name ftracker \
|
||||
-e DOMAIN=example.com \
|
||||
-e LE_EMAIL=admin@example.com \
|
||||
-p 80:80 \
|
||||
-p 443:443 \
|
||||
-v /your/full/path/to/config.ini:/etc/ftracker/config.ini \
|
||||
fasttube/ftracker
|
||||
```
|
||||
|
||||
Otherwise you can run it without SSL (maybe behind your own web+ssl server)
|
||||
using just
|
||||
|
||||
```bash
|
||||
sudo docker run \
|
||||
-it --rm \
|
||||
--name ftracker \
|
||||
-p 80:80 \
|
||||
-v /your/full/path/to/config.ini:/etc/ftracker/config.ini \
|
||||
fasttube/ftracker
|
||||
```
|
||||
|
||||
If those work in the foreground and everything looks okay, you can start them
|
||||
without `-it --rm` and with `-d` instead to run them in the background. Keep in
|
||||
mind that it can take around 10 seconds to fully start.
|
||||
|
||||
To stop/start/uninstall the container afterwards, run:
|
||||
|
||||
```bash
|
||||
sudo docker stop ftracker # might take a few seconds
|
||||
sudo docker start ftracker # continue running
|
||||
sudo docker rm -f ftracker # uninstall
|
||||
```
|
||||
|
||||
### Method B: Manual
|
||||
|
||||
#### 1. FTracker Backend
|
||||
|
||||
Install backend system wide:
|
||||
```bash
|
||||
# clone, cd into repo
|
||||
sudo -H pip install . # Use -e if you want to hack on the backend while installed.
|
||||
```
|
||||
|
||||
#### 2. WSGI Server + Service file
|
||||
|
||||
You need a WSGI Middleware (using `Flask`'s included `werkzeug` is discouraged
|
||||
for production environments). I recommend `uwsgi` since it's flexible, fast and
|
||||
has `nginx` integration. A sample configuration file as well as service
|
||||
description files for both `systemd` and `rc` are included in `res/` for you to
|
||||
adapt (file paths etc.) and install to your system (The `systemd` service file
|
||||
still untested though, feel free to leave feedback).
|
||||
|
||||
#### 3. Webserver
|
||||
|
||||
You need a webserver. I recommend `nginx` because it's the industry standard
|
||||
and fast. A sample config file is included in `res/` for you to adapt (domain,
|
||||
SSL certs) and install to your system. The configuration should include:
|
||||
Webroot in `web/` with a fallback to the WSGI handler for the backend.
|
||||
|
||||
Enabling SSL (https) and redirecting http to https is strongly encouraged, i
|
||||
recommend using `Let's Encrypt`'s `certbot` to easily obtain certificates.
|
||||
|
||||
#### 4. Start/Restart
|
||||
|
||||
Edit `config.ini` to your liking. Restart the backend by restarting the `uwsgi`
|
||||
service, e.g. `sudo systemctl restart ftracker` or `sudo service ftracker
|
||||
restart`. see below for customization options.
|
||||
|
||||
## Customization
|
||||
|
||||
`ftracker` has a couple of ways you can make it your own. Here is a breakdown
|
||||
of the functionalities.
|
||||
|
||||
The configuration file is in the `ini` format and all options are in the
|
||||
`[global]` section. It should be placed in/mounted at
|
||||
`/etc/ftracker/config.ini` or passed to the python module as the first
|
||||
argument.
|
||||
|
||||
### Storage
|
||||
|
||||
`ftracker` uses `TinyDB` for data storage, which is essentially a `json` file.
|
||||
For manual installations, you can decide where on your filesystem it should be
|
||||
using the `db_file` option. We recommend something like `/var/ftracker/db.json`
|
||||
for which you'd need to create the `/var/ftracker` directory and set its
|
||||
permissions to your webserver user. The docker container handles this
|
||||
internally.
|
||||
|
||||
### Data access
|
||||
|
||||
The 'data view' at `/view` is used to view the attendance data. It is protected
|
||||
using a hard-configured username/password combo because i'm lazy and we didn't
|
||||
need anything more fancy. We recommend you choose a safe, unguessable password
|
||||
to protect the attendance data using the `admin_user` and `admin_pass` config
|
||||
options. Please note: Location data is very sensitive data and has to be
|
||||
handled carefully under the GDPR. Make sure all users know what happens to
|
||||
their data and who has access to it.
|
||||
|
||||
### Guidelines
|
||||
|
||||
When arriving/signing in, users have to agree to a set of guidelines. Insert
|
||||
a link to a publicly hosted version of your guideline document in the
|
||||
`guideline_url` option to allow users to find it easily.
|
||||
|
||||
### List of allowed names
|
||||
|
||||
Some places might require all users arriving/signing in to be in a list of
|
||||
pre-approved users (i.e. users that signed a permit). To block all names not in
|
||||
such a list, provide a CSV file with all approved names (and optionally email
|
||||
adresses for future features ;)) and enter the file's full location in the
|
||||
`name_file` option. The recommended location is `/var/ftracker/namelist.csv` or
|
||||
similar.
|
||||
|
||||
If you're using docker, you need to mount this file to the location specified
|
||||
in `name_file` when `run`ning the container using an argument like `-v
|
||||
/your/host/path/to/namelist.csv:/var/ftracker/namelist.csv`.
|
||||
|
||||
The CSV format is:
|
||||
|
||||
```
|
||||
Jane,Doe,j.doe@example.com
|
||||
First Name,Last Name,f.lastname@example.com
|
||||
```
|
||||
|
||||
Users have to enter their full name like `Jane Doe` in order to be admitted.
|
||||
The names are slugified for comparison, meaning case, accents and double spaces
|
||||
are ignored (i.e.
|
||||
|
||||
```
|
||||
jänE doé
|
||||
```
|
||||
|
||||
would still work, but `Jane D` wouldn't).
|
||||
|
||||
### Automatic data deletion (GDPR compliance)
|
||||
|
||||
The `delete_after_days` configuration option can be set to a number of days
|
||||
after which attendance records are purged from the database. If it is not set
|
||||
(or empty) automatic deletion is deactivated. Automatic deletion is final and
|
||||
non-recoverable. This option is intended to help make the system fully GDPR
|
||||
compliant by guaranteeing deletion after a certain period. Keep in mind that a
|
||||
legally binding data protection guideline and user consent are still required.
|
||||
|
||||
### User notification on forgotten sign-out
|
||||
|
||||
`ftracker` is capable of notifying users if they forgot to sign-out at the end
|
||||
of a day using modern web push notifications using the VAPID system. To make
|
||||
this work, a few things are needed:
|
||||
|
||||
Firstly, you need an EC-Prime256v1 keypair in base64url encoding. If you're
|
||||
using the Docker container, this is automatically generated for you. If not,
|
||||
the easiest way to create one is to install the `web-push` `npm` package and
|
||||
run it:
|
||||
|
||||
```bash
|
||||
sudo npm install -g web-push
|
||||
web-push generate-vapid-keys
|
||||
```
|
||||
|
||||
The keys then need to be copied into the config options `push_public_key` and
|
||||
`push_private_key` respectively so the backend can handle the rest.
|
||||
|
||||
Next, to be VAPID compliant you have to announce an contact address claim to
|
||||
the push services so they can contact you if anything is going wrong with your
|
||||
notifications. Do this by entering your email address as a `mailto:` link in
|
||||
the `push_sender_info` option, like `mailto:it@fasttube.de`.
|
||||
|
||||
Finally, you can use the `notify_after_hrs` option to specify how long the
|
||||
system should wait after a user's arrival to notify them of their missing
|
||||
departure.
|
||||
38
README.md
38
README.md
@ -1,25 +1,47 @@
|
||||
# FT Corona Tracker
|
||||
# FaSTTUBe Corona Tracker
|
||||
|
||||
Small webapp to track who was in which room at which time to backtrace potential viral infections.
|
||||
Small webapp to track who was in which room at which time to backtrace
|
||||
potential viral infections.
|
||||
|
||||
**WORK IN PROGRESS** This project is still under heavy construction and not ready for use in production.
|
||||
For Ideas, Progress, and Bugs visit
|
||||
[Issues](https://git.fasttube.de/FaSTTUBe/ftracker/issues).
|
||||
|
||||
For Ideas and Progress see [Issues](https://git.fasttube.de/FaSTTUBe/ft-corona-tracker/issues).
|
||||
## Requirements
|
||||
|
||||
- Unixoid system (linux, BSD, macOS). Windows might also work.
|
||||
- `python` 3.6+ (might be `python3` on your system)
|
||||
- `pip` for python 3+ (might be `pip3` on your system)
|
||||
|
||||
## How to run
|
||||
|
||||
(Dev setup, prod setup not finished yet)
|
||||
(Dev setup, for prod deployment see below)
|
||||
|
||||
```bash
|
||||
# clone, cd into repo
|
||||
pip3 install -e .
|
||||
pip install -e .
|
||||
python3 -m ftracker
|
||||
```
|
||||
|
||||
Then, point your browser at <http://localhost:5000/?arrival=42>.
|
||||
Edit `config.ini` to tune your installation (see
|
||||
[INSTALL.md #customization](https://git.fasttube.de/FaSTTUBe/ft-corona-tracker/src/branch/master/INSTALL.md#customization)
|
||||
for customization options).
|
||||
|
||||
Then, point your browser at <http://localhost:5000/>.
|
||||
|
||||
## Installation/Deployment
|
||||
|
||||
See
|
||||
[INSTALL.md](https://git.fasttube.de/FaSTTUBe/ft-corona-tracker/src/branch/master/INSTALL.md)
|
||||
|
||||
## Open Sources
|
||||
|
||||
This project uses the `QRCode.js` library (Copyright (C) 2012 davidshimjs)
|
||||
licensed under the MIT License, see `web/qrcodejs/LICENSE`. Thanks!
|
||||
|
||||
## License
|
||||
|
||||
Licensed under GNU GPL v3, see [LICENSE.md](https://git.fasttube.de/FaSTTUBe/ft-corona-tracker/src/branch/master/LICENSE.md) for details.
|
||||
FTracker is licensed under the GNU GPL v3 license, see
|
||||
[LICENSE.md](https://git.fasttube.de/FaSTTUBe/ft-corona-tracker/src/branch/master/LICENSE.md)
|
||||
for details.
|
||||
|
||||
Copyright (C) 2020 Oskar/FaSTTUBe
|
||||
|
||||
15
config.ini
15
config.ini
@ -4,10 +4,13 @@
|
||||
# Remove or leave empty for temporary (/tmp/ftracker-db.json) storage
|
||||
db_file = db.json
|
||||
|
||||
# Delete all information after X days (e.g. for GDPR compliance)
|
||||
delete_after_days = 28
|
||||
|
||||
# List of people to be allowed, in .csv format (comma, no delimiters)
|
||||
# Col1: First Name(s), Col2: Last Name(s), Col3 (optional): EMail
|
||||
# Remove or leave empty for no check
|
||||
name_file = /root/namensliste.csv
|
||||
name_file = namensliste.csv
|
||||
|
||||
|
||||
# Username and password for data retrieval
|
||||
@ -21,3 +24,13 @@ guideline_url = https://fasttube.de/wp-content/uploads/2020/12/Cororna-Regeln-St
|
||||
|
||||
# JSON indentation for debugging
|
||||
json_indent = 4
|
||||
|
||||
# VAPID credentials for push notifications
|
||||
# private key: base64url encoded public part of an EC-Prime256v1 keypair. See INSTALL.md
|
||||
# private key: base64url encoded private part of an EC-Prime256v1 keypair. See INSTALL.md
|
||||
# sender info: usually mailto link to responsible party to contact about issues
|
||||
push_public_key = BBwBPYxhogHLU3B1FpxfQNzO3q7qZpmD1n1KaaL8WJbcVmJSHhi1uB-VmvsVjjUHWYCeqKyLT7w-1LBfpIcbbcg
|
||||
push_private_key = abcdefghijklm_NOPQRSTUVWXYZ-0123456789
|
||||
push_sender_info = mailto:it@fasttube.de
|
||||
# when to notify users, in hours after arrival
|
||||
notify_after_hrs = 10
|
||||
|
||||
@ -1 +1 @@
|
||||
from .core import *
|
||||
from .core import app
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from .core import *
|
||||
from pathlib import Path
|
||||
from .core import app
|
||||
|
||||
# Start the flask server if run from terminal
|
||||
if __name__ == "__main__":
|
||||
@ -7,12 +8,15 @@ if __name__ == "__main__":
|
||||
def get_root():
|
||||
return app.send_static_file('index.html')
|
||||
|
||||
@app.route('/view')
|
||||
def get_view():
|
||||
return app.send_static_file('viewdata.html')
|
||||
|
||||
@app.route('/<path:path>')
|
||||
def get_file(path):
|
||||
def get_path(path):
|
||||
fpath = f"{app.static_folder}/{path}"
|
||||
|
||||
# Prettier URLs by auto-loading <path>.html
|
||||
# Our nginx config does this as well
|
||||
if not Path(fpath).is_file():
|
||||
return app.send_static_file(path + '.html')
|
||||
|
||||
return app.send_static_file(path)
|
||||
|
||||
# Just allow everything to avoid the hassle when running locally.
|
||||
|
||||
@ -6,6 +6,6 @@
|
||||
|
||||
# Corona time tracker
|
||||
|
||||
VERSION = (0, 0, 1)
|
||||
VERSION = (1, 1, 0)
|
||||
|
||||
__version__ = '.'.join(map(str, VERSION))
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import sys, os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from configparser import ConfigParser
|
||||
|
||||
class Config:
|
||||
@ -8,17 +9,17 @@ class Config:
|
||||
def findConfigFile():
|
||||
if len(sys.argv) > 1:
|
||||
return sys.argv[1]
|
||||
elif os.path.isfile('config.ini'):
|
||||
return 'config.ini'
|
||||
elif os.path.isfile('/etc/ftracker/config.ini'):
|
||||
elif Path('/etc/ftracker/config.ini').is_file():
|
||||
return '/etc/ftracker/config.ini'
|
||||
elif Path('config.ini').is_file():
|
||||
return 'config.ini'
|
||||
else:
|
||||
return None
|
||||
|
||||
configfile = findConfigFile()
|
||||
|
||||
if configfile:
|
||||
self.config = ConfigParser()
|
||||
self.config = ConfigParser(strict=False)
|
||||
self.config.read(configfile)
|
||||
else:
|
||||
raise Exception("No config file found")
|
||||
@ -29,3 +30,6 @@ class Config:
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.config['global'].get(key)
|
||||
|
||||
def __repr__(self):
|
||||
return repr(self.config.items('global'))
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import atexit
|
||||
import json
|
||||
from datetime import datetime
|
||||
from slugify import slugify
|
||||
@ -21,6 +22,27 @@ from flask import Flask, request, redirect
|
||||
app = Flask(__name__, static_folder='../web')
|
||||
|
||||
|
||||
if config['delete_after_days']:
|
||||
from .deleter import Deleter
|
||||
deleter = Deleter(db, int(config['delete_after_days']))
|
||||
|
||||
|
||||
if config['notify_after_hrs']:
|
||||
from .notifier import Notifier
|
||||
notifier = Notifier(db, int(config['notify_after_hrs']), {
|
||||
'private_key': config['push_private_key'],
|
||||
'claims': {'sub': config['push_sender_info']}
|
||||
})
|
||||
|
||||
|
||||
def shutdown():
|
||||
print('\rReceived stop signal, stopping threads...')
|
||||
deleter.stop()
|
||||
db.close()
|
||||
|
||||
atexit.register(shutdown)
|
||||
|
||||
|
||||
@app.route('/guidelines')
|
||||
def get_guidelines():
|
||||
dest = config['guideline_url'] or None
|
||||
@ -65,6 +87,7 @@ def post_arrival():
|
||||
now = datetime.utcnow().isoformat() + 'Z'
|
||||
db.insert({
|
||||
'name': name,
|
||||
'tested': data.get('tested') or False,
|
||||
'room': data['room'],
|
||||
'arrival': data.get('arrival') or now,
|
||||
'departure': None
|
||||
@ -125,7 +148,12 @@ def get_data():
|
||||
|
||||
start = request.args.get('start', default = None, type = str)
|
||||
end = request.args.get('end' , default = None, type = str)
|
||||
room = request.args.get('room' , default = ".*", type = str)
|
||||
room = request.args.get('room' , default = None, type = str)
|
||||
|
||||
# Skip DB query if no parameters given
|
||||
# (Which is currently every request)
|
||||
if not (start or end or room):
|
||||
return json.dumps(db.all(), indent=SPACES), 200
|
||||
|
||||
def is_after(val, iso):
|
||||
return (val >= iso if val else True ) if iso else True
|
||||
@ -137,7 +165,66 @@ def get_data():
|
||||
r = db.search(
|
||||
(Entry.departure.test(is_after, start)) &
|
||||
(Entry.arrival.test(is_before, end)) &
|
||||
(Entry.room.search(room))
|
||||
(Entry.room.search(room or ".*"))
|
||||
)
|
||||
|
||||
return json.dumps(r, indent=SPACES), 200
|
||||
|
||||
|
||||
@app.route('/pushinfo')
|
||||
def get_pushinfo():
|
||||
|
||||
if config['notify_after_hrs']:
|
||||
|
||||
r = {
|
||||
'enabled': True,
|
||||
'publickey': config['push_public_key']
|
||||
}
|
||||
|
||||
else:
|
||||
|
||||
r = {
|
||||
'enabled': False,
|
||||
'publickey': None
|
||||
}
|
||||
|
||||
return json.dumps(r, indent=SPACES), 200
|
||||
|
||||
@app.route('/pushsubscribe', methods=['POST'])
|
||||
def post_pushsub():
|
||||
|
||||
try:
|
||||
payload = request.data.decode('UTF-8')
|
||||
data = json.loads(payload)
|
||||
except ValueError as e:
|
||||
return 'Error: JSON decode error:\n' + str(e), 400
|
||||
|
||||
notifier.subscribe_user(data)
|
||||
|
||||
return 'OK', 201
|
||||
|
||||
@app.route('/testpush/<name>')
|
||||
def testpush(name):
|
||||
|
||||
if not 'Authorization' in request.headers:
|
||||
return 'Error: No Authorization', 401, {'WWW-Authenticate': 'Basic'}
|
||||
|
||||
if request.authorization.username != config['admin_user']:
|
||||
return "Wrong username", 403
|
||||
|
||||
if request.authorization.password != config['admin_pass']:
|
||||
return "Wrong password", 403
|
||||
|
||||
Entry = Query()
|
||||
arrivals = db.search((Entry.name == name) & (Entry.departure == None))
|
||||
|
||||
if len(arrivals) == 0:
|
||||
print("User is not logged in :(")
|
||||
return "Error: User is not logged in :(", 409
|
||||
|
||||
error = notifier.notify_user(arrivals[0])
|
||||
|
||||
if error:
|
||||
return 'Error: ' + error, 404
|
||||
|
||||
return 'OK', 201
|
||||
|
||||
42
ftracker/deleter.py
Normal file
42
ftracker/deleter.py
Normal file
@ -0,0 +1,42 @@
|
||||
from threading import Thread, Event
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from tinydb import Query
|
||||
|
||||
ONE_DAY_IN_S = 24 * 60 * 60
|
||||
|
||||
class Deleter(Thread):
|
||||
|
||||
def delete_old_entries(self):
|
||||
|
||||
print('Clearing database of old entries...')
|
||||
|
||||
td = timedelta(days=self.days)
|
||||
|
||||
threshold = datetime.utcnow() - td
|
||||
|
||||
iso = threshold.isoformat() + 'Z'
|
||||
|
||||
Entry = Query()
|
||||
self.db.remove((Entry.arrival < iso))
|
||||
|
||||
print('Deleted everything until UTC', iso)
|
||||
|
||||
def __init__(self, db, days):
|
||||
|
||||
self.db = db
|
||||
self.days = days
|
||||
|
||||
self.delete_old_entries()
|
||||
|
||||
Thread.__init__(self, daemon=True)
|
||||
self.stopped = Event()
|
||||
self.start()
|
||||
|
||||
def run(self):
|
||||
|
||||
while not self.stopped.wait(ONE_DAY_IN_S):
|
||||
self.delete_old_entries()
|
||||
|
||||
def stop(self):
|
||||
self.stopped.set()
|
||||
100
ftracker/notifier.py
Normal file
100
ftracker/notifier.py
Normal file
@ -0,0 +1,100 @@
|
||||
import json
|
||||
import copy
|
||||
from slugify import slugify
|
||||
from threading import Thread, Event
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from tinydb import Query
|
||||
|
||||
from pywebpush import webpush, WebPushException
|
||||
|
||||
ONE_HOUR_IN_S = 60 * 60
|
||||
|
||||
class Notifier(Thread):
|
||||
|
||||
def subscribe_user(self, data):
|
||||
|
||||
pushsubs = self.db.table('pushsubs')
|
||||
|
||||
name = slugify(data['name'])
|
||||
data['name'] = name
|
||||
|
||||
Entry = Query()
|
||||
pushsubs.upsert(data, Entry.name == name)
|
||||
|
||||
def notify_user(self, arrival):
|
||||
|
||||
pushsubs = self.db.table('pushsubs')
|
||||
|
||||
Entry = Query()
|
||||
ps = pushsubs.search(Entry.name == arrival['name'])
|
||||
if len(ps) == 0:
|
||||
print("User is not subscribed to notifications :(")
|
||||
return "User is not subscribed to notifications :("
|
||||
|
||||
ps = ps[0]
|
||||
|
||||
print("Sending notification", arrival, ps, self.vapid_creds)
|
||||
|
||||
subscription = ps['sub']
|
||||
notification = {
|
||||
'title': "Forgot to sign out?",
|
||||
'body': "You didn't sign out of ftracker yet",
|
||||
'arr': arrival
|
||||
}
|
||||
privkey = self.vapid_creds['private_key']
|
||||
claims = copy.copy(self.vapid_creds['claims'])
|
||||
|
||||
try:
|
||||
webpush(
|
||||
subscription,
|
||||
json.dumps(notification),
|
||||
vapid_private_key = privkey,
|
||||
vapid_claims = claims
|
||||
)
|
||||
print("Notification sent")
|
||||
return None
|
||||
except WebPushException as exc:
|
||||
print("Notification failed", exc)
|
||||
return repr(exc)
|
||||
|
||||
|
||||
def notify_logged_in_users(self):
|
||||
|
||||
print("Notifying users that aren't signed out yet...")
|
||||
|
||||
td = timedelta(hours=self.hours)
|
||||
|
||||
threshold = datetime.utcnow() - td
|
||||
|
||||
iso = threshold.isoformat() + 'Z'
|
||||
|
||||
Entry = Query()
|
||||
arrivals = self.db.search(
|
||||
(Entry.arrival < iso) & (Entry.departure == None)
|
||||
)
|
||||
|
||||
for arrival in arrivals:
|
||||
self.notify_user(arrival)
|
||||
|
||||
print("Notified everything until UTC", iso)
|
||||
|
||||
def __init__(self, db, hours, vapid_creds):
|
||||
|
||||
self.db = db
|
||||
self.hours = hours
|
||||
self.vapid_creds = vapid_creds
|
||||
|
||||
self.notify_logged_in_users()
|
||||
|
||||
Thread.__init__(self, daemon=True)
|
||||
self.stopped = Event()
|
||||
self.start()
|
||||
|
||||
def run(self):
|
||||
|
||||
while not self.stopped.wait(ONE_HOUR_IN_S):
|
||||
self.notify_logged_in_users()
|
||||
|
||||
def stop(self):
|
||||
self.stopped.set()
|
||||
36
res/config.deploy.ini
Normal file
36
res/config.deploy.ini
Normal file
@ -0,0 +1,36 @@
|
||||
[global]
|
||||
|
||||
# Persistent file for storage of times, in .json format.
|
||||
# Remove or leave empty for temporary (/tmp/ftracker-db.json) storage
|
||||
db_file = /var/ftracker/db.json
|
||||
|
||||
# Delete all information after X days (e.g. for GDPR compliance)
|
||||
delete_after_days = 28
|
||||
|
||||
# List of people to be allowed, in .csv format (comma, no delimiters)
|
||||
# Col1: First Name(s), Col2: Last Name(s), Col3 (optional): EMail
|
||||
# Remove or leave empty for no check
|
||||
name_file =
|
||||
|
||||
|
||||
# Username and password for data retrieval
|
||||
admin_user = admin
|
||||
admin_pass = topSecret
|
||||
|
||||
|
||||
# Link to a document with guidelines for entering
|
||||
guideline_url = https://youtu.be/oHg5SJYRHA0
|
||||
|
||||
|
||||
# JSON indentation for debugging
|
||||
json_indent = 4
|
||||
|
||||
# VAPID credentials for push notifications
|
||||
# private key: base64url encoded public part of an EC-Prime256v1 keypair. See INSTALL.md
|
||||
# private key: base64url encoded private part of an EC-Prime256v1 keypair. See INSTALL.md
|
||||
# sender info: usually mailto link to responsible party to contact about issues
|
||||
push_public_key = abcdefghijklm_NOPQRSTUVWXYZ-0123456789abcdefghijklm_NOPQRSTUVWXYZ-0123456789abcdefghijklm_NOPQRSTUVWXYZ-0123456789
|
||||
push_private_key = abcdefghijklm_NOPQRSTUVWXYZ-0123456789
|
||||
push_sender_info = mailto:admin@example.com
|
||||
# when to notify users, in hours after arrival
|
||||
notify_after_hrs = 10
|
||||
49
res/docker-entrypoint.sh
Normal file
49
res/docker-entrypoint.sh
Normal file
@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo " >>> Checking / Creating & patching VAPID creds <<< "
|
||||
|
||||
VAPID_CREDS_FILE=/etc/ftracker/vapid-creds.json
|
||||
if [[ ! -f $VAPID_CREDS_FILE ]]
|
||||
then
|
||||
|
||||
echo "Generating keypair ..."
|
||||
|
||||
web-push generate-vapid-keys --json > $VAPID_CREDS_FILE
|
||||
|
||||
echo "Patching keypair into config ..."
|
||||
PUB_KEY=`cat $VAPID_CREDS_FILE | jq -r .publicKey`
|
||||
echo "pushServerPublicKey = ${PUB_KEY}" >> /var/www/html/ftracker/main.js
|
||||
|
||||
PRIV_KEY=`cat $VAPID_CREDS_FILE | jq -r .privateKey`
|
||||
echo "push_private_key = ${PRIV_KEY}" >> /etc/ftracker/config.ini
|
||||
|
||||
fi
|
||||
|
||||
echo " >>> Starting nginx <<< "
|
||||
|
||||
mkdir /run/nginx # needed because of bug in package
|
||||
/usr/sbin/nginx -t
|
||||
/usr/sbin/nginx
|
||||
|
||||
echo " >>> Checking / Installing SSL certificate <<< "
|
||||
|
||||
if [[ ${DOMAIN} ]]
|
||||
then
|
||||
echo "Obtaining cert for '${DOMAIN}' ..."
|
||||
echo "Registering with email '${LE_EMAIL}' ..."
|
||||
|
||||
certbot -n \
|
||||
--nginx \
|
||||
--keep-until-expiring \
|
||||
--redirect \
|
||||
--agree-tos \
|
||||
--cert-name ${DOMAIN} \
|
||||
-d ${DOMAIN} \
|
||||
-m ${LE_EMAIL}
|
||||
|
||||
echo "Checked/Installed SSL certificate."
|
||||
fi
|
||||
|
||||
echo " >>> Starting uwsgi <<< "
|
||||
|
||||
exec /usr/sbin/uwsgi --ini /root/ftracker/res/ftracker.alpine.uwsgi.ini
|
||||
12
res/ftracker.alpine.uwsgi.ini
Normal file
12
res/ftracker.alpine.uwsgi.ini
Normal file
@ -0,0 +1,12 @@
|
||||
[uwsgi]
|
||||
plugin = python3
|
||||
module = ftracker:app
|
||||
socket = /tmp/ftracker.sock
|
||||
manage-script-name = true
|
||||
master = true
|
||||
|
||||
uid = nginx
|
||||
gid = nginx
|
||||
|
||||
proesses = 1
|
||||
threads = 1
|
||||
12
res/ftracker.debian.uwsgi.ini
Normal file
12
res/ftracker.debian.uwsgi.ini
Normal file
@ -0,0 +1,12 @@
|
||||
[uwsgi]
|
||||
plugin = python3
|
||||
module = ftracker:app
|
||||
socket = /tmp/ftracker.sock
|
||||
manage-script-name = true
|
||||
master = true
|
||||
|
||||
uid = www-data
|
||||
gid = www-data
|
||||
|
||||
proesses = 1
|
||||
threads = 1
|
||||
@ -3,14 +3,14 @@ server {
|
||||
|
||||
listen 443 ssl;
|
||||
|
||||
root /root/ft-corona-tracker/web;
|
||||
root /root/ftracker/web;
|
||||
|
||||
index index.html index.htm;
|
||||
|
||||
location / {
|
||||
# First attempt to serve request as file
|
||||
# If no such file, show index to allow for client side routing
|
||||
try_files $uri $uri/ @api;
|
||||
# If no such file, pass to backend
|
||||
try_files $uri $uri/ $uri.html @api;
|
||||
}
|
||||
|
||||
location @api {
|
||||
|
||||
24
res/ftracker.nossl.nginx.conf
Normal file
24
res/ftracker.nossl.nginx.conf
Normal file
@ -0,0 +1,24 @@
|
||||
server {
|
||||
|
||||
listen 80 default_server;
|
||||
|
||||
root /var/www/html/ftracker;
|
||||
|
||||
index index.html index.htm;
|
||||
|
||||
location / {
|
||||
# First attempt to serve request as file
|
||||
# If no such file, pass to backend
|
||||
try_files $uri $uri/ $uri.html @api;
|
||||
}
|
||||
|
||||
location @api {
|
||||
include uwsgi_params;
|
||||
# Pass it to the uwsgi server
|
||||
uwsgi_pass unix:///tmp/ftracker.sock;
|
||||
}
|
||||
|
||||
# RIP
|
||||
add_header X-Clacks-Overhead "GNU Terry Pratchett" always;
|
||||
|
||||
}
|
||||
@ -15,7 +15,7 @@ rcvar="ftracker_enable"
|
||||
|
||||
pidfile="/var/run/${name}.pid"
|
||||
logfile="/var/log/${name}.log"
|
||||
configfile="/root/ft-corona-tracker/res/ftracker.uwsgi.ini"
|
||||
configfile="/root/ftracker/res/ftracker.uwsgi.ini"
|
||||
|
||||
command="/usr/local/bin/uwsgi";
|
||||
command_args="--ini ${configfile} --daemonize ${logfile} --pidfile ${pidfile}"
|
||||
|
||||
@ -5,7 +5,7 @@ After=syslog.target network.target nginx.service
|
||||
# Configuration mostly stolen from from uwsgi docs
|
||||
[Service]
|
||||
User=www-data
|
||||
ExecStart=/usr/bin/uwsgi --ini /root/ft-corona-tracker/res/ftracker.uwsgi.ini
|
||||
ExecStart=/usr/bin/uwsgi --ini /root/ftracker/res/ftracker.uwsgi.ini
|
||||
RuntimeDirectory=uwsgi
|
||||
Restart=always
|
||||
KillSignal=SIGQUIT
|
||||
|
||||
7
setup.py
7
setup.py
@ -3,23 +3,24 @@ import setuptools as st
|
||||
with open("README.md", "r") as f:
|
||||
long_description = f.read()
|
||||
|
||||
with open('LICENSE.md') as f:
|
||||
with open("LICENSE.md", "r") as f:
|
||||
license_text = f.read()
|
||||
|
||||
st.setup(
|
||||
name="ftracker",
|
||||
version="0.1.0",
|
||||
version="1.1.0",
|
||||
author="Oskar @ FaSTTUBe",
|
||||
author_email="o.winkels@fasttube.de",
|
||||
description="Small webapp to track who was in which room at which time to backtrace potential viral infections",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://git.fasttube.de/FaSTTUBe/ft-corona-tracker",
|
||||
url="https://git.fasttube.de/FaSTTUBe/ftracker",
|
||||
packages=st.find_packages(exclude=['tests', 'docs']),
|
||||
install_requires=[
|
||||
"flask",
|
||||
"tinydb",
|
||||
"python-slugify",
|
||||
"pywebpush",
|
||||
],
|
||||
license=license_text,
|
||||
classifiers=[
|
||||
|
||||
158
web/QRgen.html
Normal file
158
web/QRgen.html
Normal file
@ -0,0 +1,158 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>FTracker</title>
|
||||
<meta name="theme-color" content="#c50e1f">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: #ddd;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
text-transform: uppercase;
|
||||
color: #eee;
|
||||
background: #c50e1f;
|
||||
text-align: center;
|
||||
}
|
||||
form {
|
||||
padding: 16px;
|
||||
max-width: 512px;
|
||||
margin: auto;
|
||||
}
|
||||
input {
|
||||
border: none;
|
||||
padding: 16px;
|
||||
margin: 4px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
input[type=text] {
|
||||
color: #000;
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
input[type=submit] {
|
||||
background: #c50e1f;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.print {
|
||||
display: none;
|
||||
text-align: center;
|
||||
}
|
||||
.print * {
|
||||
display: inline-block;
|
||||
margin: 42px auto;
|
||||
}
|
||||
.print .link {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.print h1 {
|
||||
margin-top: 64px;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="formView">
|
||||
<h1>FTracker<br>Door Sign Genrator</h1>
|
||||
<form id="roomform">
|
||||
<label>
|
||||
Room Nr/Name:<br>
|
||||
<input type="text" name="room" id="room" placeholder="123" required>
|
||||
</label>
|
||||
<input type="submit" value="Print">
|
||||
</form>
|
||||
</main>
|
||||
<main id="printA" class="print">
|
||||
<h1 class="title"></h1><br>
|
||||
<div class="qr"></div><br>
|
||||
<span class="link"></span><br>
|
||||
<span>
|
||||
Made with FTracker<br>
|
||||
https://git.fasttube.de/FaSTTUBe/ftracker<br>
|
||||
© 2020 Oskar / FaSTTUBe
|
||||
</span>
|
||||
</main>
|
||||
<main id="printD" class="print">
|
||||
<h1 class="title"></h1><br>
|
||||
<div class="qr"></div><br>
|
||||
<span class="link"></span><br>
|
||||
<span>
|
||||
Made with FTracker<br>
|
||||
https://git.fasttube.de/FaSTTUBe/ftracker<br>
|
||||
© 2020 Oskar / FaSTTUBe
|
||||
</span>
|
||||
</main>
|
||||
<script src="/qrcodejs/qrcode.min.js"></script>
|
||||
<script>
|
||||
var fv = document.getElementById('formView')
|
||||
var pa = document.getElementById('printA')
|
||||
var pd = document.getElementById('printD')
|
||||
|
||||
var rform = document.getElementById('roomform')
|
||||
rform.onsubmit = function(e) {
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
var room = e.srcElement[0].value
|
||||
|
||||
writePage(pa, room, 'arrival')
|
||||
writePage(pd, room, 'departure')
|
||||
|
||||
printPage(pa, 'ftracker-arrival-'+room)
|
||||
printPage(pd, 'ftracker-departure-'+room)
|
||||
|
||||
}
|
||||
|
||||
function writePage(el, room, type) {
|
||||
|
||||
var base = location.href.split('/').slice(0,3).join('/')
|
||||
|
||||
var url = base + '/?' + type + '=' + room
|
||||
|
||||
var title = el.querySelector('.title')
|
||||
var qr = el.querySelector('.qr')
|
||||
var link = el.querySelector('.link')
|
||||
|
||||
title.innerHTML =
|
||||
'Scan here to log ' + type + '<br> in room ' + room
|
||||
link.innerHTML = url
|
||||
qr.innerHTML = ''
|
||||
new QRCode(qr, {
|
||||
text: url,
|
||||
width: 320,
|
||||
height: 320
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
function printPage(el, name) {
|
||||
|
||||
var t = document.title
|
||||
document.title = name
|
||||
|
||||
fv.style.display = 'none'
|
||||
el.style.display = 'block'
|
||||
|
||||
window.print()
|
||||
|
||||
el.style.display = 'none'
|
||||
fv.style.display = 'block'
|
||||
|
||||
document.title = t
|
||||
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
296
web/index.html
296
web/index.html
@ -2,281 +2,77 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>FTracker</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="theme-color" content="#c50e1f">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: #ddd;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
text-transform: uppercase;
|
||||
color: #eee;
|
||||
background: #c50e1f;
|
||||
text-align: center;
|
||||
}
|
||||
form {
|
||||
padding: 16px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: #444;
|
||||
}
|
||||
label#agreelabel {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
label span {
|
||||
width: calc(100% - 50px);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
line-height: normal;
|
||||
}
|
||||
input {
|
||||
border: none;
|
||||
padding: 16px;
|
||||
margin: 4px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
input[type=text] {
|
||||
color: #000;
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
input[type=submit] {
|
||||
background: #c50e1f;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
input[type=checkbox] {
|
||||
transform: translateY(-3px);
|
||||
float: left;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.request {
|
||||
display: block;
|
||||
position: fixed;
|
||||
background: #ddd;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
width: calc(100% - 32px);
|
||||
box-shadow: 0 1px 4px 0;
|
||||
}
|
||||
input[type=datetime-local] {
|
||||
width: calc(100% - 24px);
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
<link href="style.css" rel="stylesheet" type="text/css">
|
||||
<script>
|
||||
// 1st script, prepares values needed for writing document
|
||||
var cbt = {
|
||||
'arrival': 'I have read and will adhere to the <a href="/guidelines" target="_blank">protection guidelines</a>',
|
||||
'departure': 'I have cleaned my workspace'
|
||||
}
|
||||
var testCheckBox = '<label class="checkbox"><input type="checkbox" name="tested" id="tested"><span>I fullfill one of the <a href="https://www.bundesregierung.de/breg-de/aktuelles/bund-laender-beratung-corona-1949606">3G requirements</a></span></label>'
|
||||
var editTimeBox = '<label>Departure Date/Time:<input type="datetime-local" name="datetime" id="datetime" required></label>'
|
||||
function getParams() {
|
||||
var h = document.location.href
|
||||
var qparam = h.split('?')[1] || null
|
||||
if (qparam == null) {
|
||||
alert("Query parameter(s) missing")
|
||||
return null
|
||||
}
|
||||
var qparams = document.location.search.substr(1)
|
||||
if (qparams == "") return {}
|
||||
qparams = qparams.split('&')
|
||||
var qps = {}
|
||||
for (var qparam of qparams) {
|
||||
var vals = qparam.split('=')
|
||||
if (vals.length < 2 || !cbt.hasOwnProperty(vals[0])) {
|
||||
alert("Invalid query parameter")
|
||||
return null
|
||||
}
|
||||
return {
|
||||
action: vals[0],
|
||||
room: vals[1]
|
||||
qps[vals[0]] = vals[1] || null
|
||||
}
|
||||
// Backwards compat
|
||||
if (qps.arrival) {qps.action = 'arrival'; qps.room = qps.arrival}
|
||||
if (qps.departure) {qps.action = 'departure'; qps.room = qps.departure}
|
||||
return qps
|
||||
}
|
||||
var qp = getParams()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1><script>
|
||||
document.write(qp.action + "<br>Room " + qp.room)
|
||||
document.write(qp.action ? (qp.action + "<br>Room " + qp.room) : 'FTracker<br>V1.1')
|
||||
</script></h1>
|
||||
<form id="mainform">
|
||||
<div id="startpage">
|
||||
This is a web app to track which people were in the same rooms at
|
||||
which times in order to backtrace potential viral infections.<br><br>
|
||||
If you've reached this page that either means your're testing
|
||||
things or something has gone quite wrong with the URL.<br>
|
||||
In the former case: Yay it works! In the latter you should
|
||||
probably contact an admin or a dev nearby :(<br><br>
|
||||
Here are a few links for testing:<br>
|
||||
<a href="/view">View Data</a>,
|
||||
<a href="/QRgen">Door Sign Generator</a>,
|
||||
<a href="/?arrival=42">Test Arrival</a>,
|
||||
<a href="/?departure=42">Test Departure</a>,
|
||||
<a href="javascript:localStorage.removeItem('pushsub')">Reset Push Subscription</a><br><br>
|
||||
© 2020 made by <a target="_blank" href="mailto:o.winkels@fasttube.de">Oskar</a>
|
||||
for <a target="_blank" href="//fasttube.de">FaSTTUBe</a>.<br>
|
||||
For source code & licensing see <a href="//git.fasttube.de/FaSTTUBe/ftracker">git repo</a>
|
||||
</div>
|
||||
<form id="mainform" action="javascript:void(0);" style="display: none">
|
||||
<label>
|
||||
Full Name:<br>
|
||||
<input type="text" name="name" id="name" placeholder="John Doe" required>
|
||||
</label>
|
||||
<label id="agreelabel">
|
||||
<input type="checkbox" name="agree" id="agree" required>
|
||||
<span><script>document.write(cbt[qp.action])</script></span>
|
||||
</label>
|
||||
<input type="submit">
|
||||
</form>
|
||||
<script>
|
||||
// Prefill the name field if it was successfully entered before
|
||||
var savedName = localStorage.getItem('name')
|
||||
if (savedName)
|
||||
document.getElementById('name').value = savedName
|
||||
|
||||
// 2nd script, server API communication
|
||||
var name, agreed
|
||||
var mform = document.getElementById('mainform')
|
||||
mform.onsubmit = function(e) {
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
name = e.srcElement[0].value
|
||||
agreed = e.srcElement[1].checked
|
||||
|
||||
sendMainData()
|
||||
|
||||
}
|
||||
|
||||
function sendMainData() {
|
||||
|
||||
// POST JSON. See docs/API.md
|
||||
var payload = (qp.action == 'arrival') ?
|
||||
{
|
||||
'room': qp.room,
|
||||
'name': name,
|
||||
'agreetoguidelines': agreed
|
||||
} :
|
||||
{
|
||||
'name': name,
|
||||
'cleanedworkspace': agreed
|
||||
}
|
||||
|
||||
post("/" + qp.action, payload)
|
||||
|
||||
}
|
||||
|
||||
function post(url, payload) {
|
||||
|
||||
console.log("Sending payload:", payload)
|
||||
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}).then(res => {
|
||||
handleResponse(res)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
function handleResponse(res) {
|
||||
|
||||
console.log("Request complete! response:", res)
|
||||
|
||||
if (Math.floor(res.status / 100) == 2) {
|
||||
|
||||
// Success
|
||||
mform = document.getElementById('mainform')
|
||||
mform.innerHTML = "<h2>Done. Thanks!</h2>"
|
||||
localStorage.setItem('name', name)
|
||||
|
||||
} else if (res.status == 409) {
|
||||
|
||||
// Conflict, more data requested
|
||||
handleRequest(res)
|
||||
|
||||
} else {
|
||||
|
||||
// Any other generic error
|
||||
res.text().then(function (text) {
|
||||
alert(text)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function handleRequestSubmit(e, json) {
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
var input = e.srcElement[0].value
|
||||
var iso = new Date(input).toISOString()
|
||||
|
||||
// POST JSON. See docs/API.md
|
||||
var payload = (json.request == 'arrival') ?
|
||||
{
|
||||
'room': qp.room,
|
||||
'name': name,
|
||||
'arrival': iso,
|
||||
'agreetoguidelines': agreed
|
||||
} :
|
||||
{
|
||||
'name': name,
|
||||
'departure': iso,
|
||||
'cleanedworkspace': agreed
|
||||
}
|
||||
|
||||
return post("/" + json.request, payload)
|
||||
|
||||
}
|
||||
|
||||
function localISOTimeMinutes(date) {
|
||||
|
||||
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
|
||||
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
|
||||
|
||||
return localISOTime.split(':').slice(0,2).join(':')
|
||||
|
||||
}
|
||||
|
||||
function handleRequest(res) {
|
||||
|
||||
var reqt = {
|
||||
'arrival': 'You probably forgot to sign in when you arrived. Please enter your arrival time now:',
|
||||
'departure': 'You probably forgot to sign out when you left. Please enter your departure time now:'
|
||||
}
|
||||
|
||||
mform.innerHTML = "<h2>Processing Request...</h2>"
|
||||
|
||||
res.json().then(function (json) {
|
||||
|
||||
var aInfo = ''
|
||||
var minD = ''
|
||||
if (json.request == 'departure') {
|
||||
var d = new Date(json.arrival.time)
|
||||
var dInfo = d.toString('en-GB').split(' ').slice(0,5).join(' ')
|
||||
aInfo = `Your last arrival was on <b>${dInfo}</b> in room <b>${json.arrival.room}</b>.`
|
||||
minD = `min="${localISOTimeMinutes(d)}"`
|
||||
}
|
||||
|
||||
var now = localISOTimeMinutes(new Date())
|
||||
|
||||
document.body.innerHTML +=
|
||||
`<div class="request">
|
||||
<h1>${json.request} missing!</h1>
|
||||
<form id="reqform">
|
||||
<label>
|
||||
${reqt[json.request]}
|
||||
<input type="datetime-local" id="datetime" ${minD} max="${now}" required>
|
||||
${aInfo}
|
||||
if (qp.edittime && qp.edittime == 1)
|
||||
document.write(editTimeBox)
|
||||
</script>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="agree" id="agree" required>
|
||||
<span><script>
|
||||
document.write(qp ? cbt[qp.action] : '')
|
||||
</script></span>
|
||||
</label>
|
||||
<script>
|
||||
if (qp.action && qp.action == 'arrival')
|
||||
document.write(testCheckBox)
|
||||
</script>
|
||||
<input type="submit">
|
||||
</form>
|
||||
</div>`
|
||||
|
||||
rform = document.getElementById('reqform')
|
||||
rform.onsubmit = async function(e) {
|
||||
await handleRequestSubmit(e, json)
|
||||
document.querySelector('.request').remove()
|
||||
setTimeout(sendMainData, 200)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
</script>
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
287
web/main.js
Normal file
287
web/main.js
Normal file
@ -0,0 +1,287 @@
|
||||
var spage = document.getElementById('startpage')
|
||||
var mform = document.getElementById('mainform')
|
||||
|
||||
if (qp.action) {
|
||||
spage.style.display = 'none'
|
||||
mform.style.display = 'block'
|
||||
}
|
||||
|
||||
// Get the name field if it was successfully entered before
|
||||
var savedName = localStorage.getItem('name')
|
||||
if (qp && qp.name) {
|
||||
// Forced Admin checkout - prefill qp name and auto-agree
|
||||
document.getElementById('name').value = qp.name.replace(/-/g, ' ').toUpperCase();
|
||||
document.getElementById('agree').checked = true
|
||||
document.getElementById('agree').parentElement.style.display = 'none'
|
||||
} else if (savedName && qp) {
|
||||
// Prefill the client's locally saved name
|
||||
document.getElementById('name').value = savedName
|
||||
}
|
||||
|
||||
// 2nd script, server API communication
|
||||
var name, datetime, agreed, tested
|
||||
mform.onsubmit = function(e) {
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
var i = 0;
|
||||
name = e.srcElement[i++].value
|
||||
if (qp.edittime && qp.edittime == 1) {
|
||||
var value = e.srcElement[i++].value
|
||||
datetime = new Date(value).toISOString()
|
||||
}
|
||||
agreed = e.srcElement[i++].checked
|
||||
if (qp.action && qp.action == 'arrival')
|
||||
tested = e.srcElement[i++].checked
|
||||
|
||||
|
||||
sendMainData()
|
||||
|
||||
initPush(name)
|
||||
|
||||
}
|
||||
|
||||
function sendMainData() {
|
||||
|
||||
// POST JSON. See docs/API.md
|
||||
var payload = (qp.action == 'arrival') ?
|
||||
{
|
||||
'room': qp.room,
|
||||
'name': name,
|
||||
'arrival': datetime,
|
||||
'agreetoguidelines': agreed,
|
||||
'tested': tested // = 3G
|
||||
} :
|
||||
{
|
||||
'name': name,
|
||||
'departure': datetime,
|
||||
'cleanedworkspace': agreed
|
||||
}
|
||||
|
||||
post("/" + qp.action, payload)
|
||||
|
||||
}
|
||||
|
||||
function post(url, payload) {
|
||||
|
||||
console.log("Sending payload:", payload)
|
||||
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(res => {
|
||||
handleResponse(res)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
function handleResponse(res) {
|
||||
|
||||
console.log("Request complete! response:", res)
|
||||
|
||||
if (Math.floor(res.status / 100) == 2) {
|
||||
|
||||
// Success
|
||||
mform = document.getElementById('mainform')
|
||||
mform.innerHTML = "<h2>Done. Thanks!</h2>"
|
||||
localStorage.setItem('name', name)
|
||||
|
||||
} else if (res.status == 409) {
|
||||
|
||||
// Conflict, more data requested
|
||||
handleRequest(res)
|
||||
|
||||
} else {
|
||||
|
||||
// Any other generic error
|
||||
res.text().then(function (text) {
|
||||
alert(text)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function handleRequestSubmit(e, json) {
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
var input = e.srcElement[0].value
|
||||
var iso = new Date(input).toISOString()
|
||||
|
||||
if (e.srcElement.length > 1)
|
||||
tested = e.srcElement[1].checked // = 3G
|
||||
|
||||
// POST JSON. See docs/API.md
|
||||
var payload = (json.request == 'arrival') ?
|
||||
{
|
||||
'room': qp.room,
|
||||
'name': name,
|
||||
'arrival': iso,
|
||||
'agreetoguidelines': agreed,
|
||||
'tested': tested // = 3G
|
||||
} :
|
||||
{
|
||||
'name': name,
|
||||
'departure': iso,
|
||||
'cleanedworkspace': agreed
|
||||
}
|
||||
|
||||
return post("/" + json.request, payload)
|
||||
|
||||
}
|
||||
|
||||
function localISOTimeMinutes(date) {
|
||||
|
||||
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
|
||||
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
|
||||
|
||||
return localISOTime.split(':').slice(0,2).join(':')
|
||||
|
||||
}
|
||||
|
||||
function handleRequest(res) {
|
||||
|
||||
var reqt = {
|
||||
'arrival': 'You probably forgot to sign in when you arrived. Please enter your arrival time now:',
|
||||
'departure': 'You probably forgot to sign out when you left. Please enter your departure time now:'
|
||||
}
|
||||
|
||||
mform.innerHTML = "<h2>Processing Request...</h2>"
|
||||
|
||||
res.json().then(function (json) {
|
||||
|
||||
var aInfo = ''
|
||||
var minD = ''
|
||||
var doubleT = ''
|
||||
var testCheck = ''
|
||||
if (json.request == 'departure') {
|
||||
var d = new Date(json.arrival.time)
|
||||
var dInfo = d.toString('en-GB').split(' ').slice(0,5).join(' ')
|
||||
aInfo = `Your last arrival was on <b>${dInfo}</b> in room <b>${json.arrival.room}</b>.`
|
||||
minD = `min="${localISOTimeMinutes(d)}"`
|
||||
if (new Date() - d < 3 * 60 * 1000) {
|
||||
doubleT = '<b style="color:red">Your last sign in was less than 3 minutes ago. You might be accidentally trying to sign in twice. If you don\'t intend to log 2 arrivals within the last 3 minutes, please abort below.</b>'
|
||||
}
|
||||
} else if (json.request == 'arrival') {
|
||||
testCheck = testCheckBox.replace('have', 'had then');
|
||||
}
|
||||
|
||||
var now = localISOTimeMinutes(new Date())
|
||||
|
||||
document.body.innerHTML +=
|
||||
`<div class="request">
|
||||
<h1>${json.request} missing!</h1>
|
||||
<form id="reqform">
|
||||
<label>
|
||||
${reqt[json.request]}
|
||||
<input type="datetime-local" ${minD} max="${now}" required>
|
||||
${aInfo}
|
||||
</label>
|
||||
${doubleT}
|
||||
${testCheck}
|
||||
<input type="submit">
|
||||
<input type="button" value="Abort" onclick="document.body.innerHTML='<h1>Aborted</h1><form>Nothing was logged.<br>You can close this tab/window now.</form>'">
|
||||
</form>
|
||||
</div>`
|
||||
|
||||
var rform = document.getElementById('reqform')
|
||||
rform.onsubmit = async function(e) {
|
||||
await handleRequestSubmit(e, json)
|
||||
document.querySelector('.request').remove()
|
||||
setTimeout(sendMainData, 200)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (qp.edittime && qp.edittime == 1) {
|
||||
var now = localISOTimeMinutes(new Date())
|
||||
document.getElementById('datetime').value = now;
|
||||
document.getElementById('datetime').max = now;
|
||||
}
|
||||
|
||||
|
||||
/* Push Notifications */
|
||||
|
||||
function sendNotification() {
|
||||
|
||||
navigator.serviceWorker.ready.then(function(serviceWorker) {
|
||||
serviceWorker.showNotification("Forgot to sign out?", {
|
||||
body: "You didn't sign out of ftracker yet",
|
||||
icon: "/favicon.ico",
|
||||
actions: [{
|
||||
action: "depart",
|
||||
title: "Sign Out"
|
||||
}]
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
function initPush(name) {
|
||||
|
||||
// Check availability
|
||||
var supported = "serviceWorker" in navigator && "PushManager" in window
|
||||
if (!supported) {
|
||||
console.warn("Push Notifications not supported!")
|
||||
return
|
||||
}
|
||||
|
||||
fetch('/pushinfo').then(function(res) {
|
||||
if (res.ok)
|
||||
res.json().then(function(push) {
|
||||
if (push.enabled)
|
||||
registerPush(name, push.publickey);
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function registerPush(name, pushServerPublicKey) {
|
||||
|
||||
// Register service worker
|
||||
navigator.serviceWorker.register("/sw.js").then(function(swRegistration) {
|
||||
console.log("ServiceWorker registered:", swRegistration)
|
||||
})
|
||||
|
||||
// Request permission
|
||||
// TODO: Only do this AFTER the first? SUCCESSFUL sign-in
|
||||
Notification.requestPermission(function(result) {
|
||||
return (result === 'granted')
|
||||
}).then(function(consent) {
|
||||
console.log('Notifications', consent ? 'enabled' : 'denied');
|
||||
})
|
||||
|
||||
// Check if already initialized
|
||||
if (localStorage.getItem('pushsub'))
|
||||
return
|
||||
|
||||
// Register push service
|
||||
navigator.serviceWorker.ready.then(function(serviceWorker) {
|
||||
serviceWorker.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: pushServerPublicKey
|
||||
}).then(function(subscription) {
|
||||
console.log("User is subscribed:", subscription);
|
||||
|
||||
var jsonSub = JSON.stringify({
|
||||
name: name,
|
||||
sub: subscription
|
||||
});
|
||||
|
||||
fetch('/pushsubscribe', {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonSub
|
||||
}).then(function(res) {
|
||||
if (res.ok)
|
||||
localStorage.setItem('pushsub', jsonSub);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
4
web/qrcodejs/.gitignore
vendored
Normal file
4
web/qrcodejs/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
|
||||
.idea
|
||||
.project
|
||||
14
web/qrcodejs/LICENSE
Normal file
14
web/qrcodejs/LICENSE
Normal file
@ -0,0 +1,14 @@
|
||||
The MIT License (MIT)
|
||||
---------------------
|
||||
Copyright (c) 2012 davidshimjs
|
||||
|
||||
Permission is hereby granted, free of charge,
|
||||
to any person obtaining a copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
1
web/qrcodejs/qrcode.min.js
vendored
Normal file
1
web/qrcodejs/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
79
web/style.css
Normal file
79
web/style.css
Normal file
@ -0,0 +1,79 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: #ddd;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
text-transform: uppercase;
|
||||
color: #eee;
|
||||
background: #c50e1f;
|
||||
text-align: center;
|
||||
}
|
||||
form, #startpage {
|
||||
padding: 16px;
|
||||
max-width: 512px;
|
||||
margin: auto;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: #444;
|
||||
}
|
||||
label.checkbox {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
label span {
|
||||
width: calc(100% - 50px);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
line-height: normal;
|
||||
}
|
||||
input {
|
||||
border: none;
|
||||
padding: 16px;
|
||||
margin: 4px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
input[type=text] {
|
||||
color: #000;
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
input[type=submit], input[type=button] {
|
||||
background: #c50e1f;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
input[type=checkbox] {
|
||||
transform: translateY(-3px);
|
||||
float: left;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.request {
|
||||
display: block;
|
||||
position: fixed;
|
||||
background: #ddd;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
width: calc(100% - 32px);
|
||||
box-shadow: 0 1px 4px 0;
|
||||
}
|
||||
input[type=datetime-local] {
|
||||
width: calc(100% - 24px);
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
35
web/sw.js
Normal file
35
web/sw.js
Normal file
@ -0,0 +1,35 @@
|
||||
function receivePushNotification(event) {
|
||||
|
||||
var data = event.data.json();
|
||||
|
||||
console.log("[Service Worker] Push Received:", data)
|
||||
|
||||
var room = data.arr ? data.arr.room : 'test'
|
||||
|
||||
var options = {
|
||||
data: `/?departure=${room}&edittime=1`,
|
||||
body: data.body,
|
||||
icon: "/favicon.ico",
|
||||
actions: [{
|
||||
action: "depart",
|
||||
title: "Sign Out"
|
||||
}]
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(data.title, options))
|
||||
|
||||
}
|
||||
|
||||
self.addEventListener("push", receivePushNotification)
|
||||
|
||||
|
||||
function openPushNotification(event) {
|
||||
|
||||
console.log("[Service Worker] Notification click Received.", event.notification.data)
|
||||
|
||||
event.notification.close()
|
||||
event.waitUntil(clients.openWindow(event.notification.data))
|
||||
|
||||
}
|
||||
|
||||
self.addEventListener("notificationclick", openPushNotification)
|
||||
127
web/view.css
Normal file
127
web/view.css
Normal file
@ -0,0 +1,127 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
header {
|
||||
height: calc(38px - 16px);
|
||||
padding: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
main {
|
||||
height: calc(100% - 38px);
|
||||
vertical-align: top;
|
||||
}
|
||||
main > section {
|
||||
height: calc(100% - 32px);
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
main > .viewheader {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
main > section.names, #nameheader {
|
||||
width: 199px;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid gray;
|
||||
font-weight: bold;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
}
|
||||
main > section.times, #timeheader {
|
||||
width: calc(100% - 200px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.scroll {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: scroll;
|
||||
}
|
||||
.row, .row #timelabels {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
background-size: 60px 100%;
|
||||
}
|
||||
.names .row:nth-child(even) { background: #ddd; }
|
||||
.names .row:nth-child(odd) { background: #eee; }
|
||||
.times .row:nth-child(even) {
|
||||
background-image: linear-gradient(to right, #bbb 1px, #ddd 1px);
|
||||
}
|
||||
.times .row:nth-child(odd) {
|
||||
background-image: linear-gradient(to right, #bbb 1px, #eee 1px);
|
||||
}
|
||||
.row span {
|
||||
height: 18px;
|
||||
padding: 7px;
|
||||
display: inline-block;
|
||||
}
|
||||
.times span, #timeheader span {
|
||||
position: absolute;
|
||||
margin-right: 16px;
|
||||
}
|
||||
.times span {
|
||||
background: #c50e1f;
|
||||
color: #ddd;
|
||||
font-weight: bold;
|
||||
-webkit-text-stroke: .4px #c50e1f;
|
||||
}
|
||||
.times span.tested { /* = 3G */
|
||||
background: rgb(0,136,0);
|
||||
}
|
||||
.times span.implausible {
|
||||
background: linear-gradient(to right, #c50e1f, rgba(197,14,31,0.2) 1000px);
|
||||
}
|
||||
.times span.implausible.tested { /* = 3G */
|
||||
background: linear-gradient(to right, rgb(0,136,0), rgba(0,136,0,0.2) 1000px);
|
||||
}
|
||||
.viewheader.row {
|
||||
height: 30px;
|
||||
background: #ddd !important;
|
||||
border-top: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
}
|
||||
.viewheader span {
|
||||
background: none !important;
|
||||
color: #000 !important;
|
||||
padding-left: 4px;
|
||||
}
|
||||
#credprompt {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 95%;
|
||||
max-width: 320px;
|
||||
margin: auto;
|
||||
background: #ddd;
|
||||
box-shadow: 0 0 0 10000px rgba(0,0,0,.75);
|
||||
}
|
||||
#credprompt h1 {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
text-transform: uppercase;
|
||||
color: #eee;
|
||||
background: #c50e1f;
|
||||
text-align: center;
|
||||
}
|
||||
#credprompt input {
|
||||
display: block;
|
||||
border: none;
|
||||
margin: 16px auto;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
#credprompt input[type=text],
|
||||
#credprompt input[type=password] {
|
||||
color: #000;
|
||||
width: calc(100% - 64px);
|
||||
}
|
||||
#credprompt input[type=submit] {
|
||||
background: #c50e1f;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
36
web/view.html
Normal file
36
web/view.html
Normal file
@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>FTracker Data</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="theme-color" content="#c50e1f">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<link href="view.css" rel="stylesheet" type="text/css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
Start: <input type="datetime-local" id="start" value="2020-12-01T00:00" onchange="renderData()">
|
||||
Ende: <input type="datetime-local" id="end" onchange="renderData()">
|
||||
Raum: <input type="text" id="room" placeholder=".* (regex)" onchange="renderData()">
|
||||
<input type="button" value="Log Out" style="float:right" onclick="localStorage.removeItem('dataauth'); location.reload()">
|
||||
<input type="button" value="Export CSV" style="float:right; margin-right: 8px;" onclick="exportCSV()">
|
||||
</header>
|
||||
<main>
|
||||
<div class="row viewheader" id="nameheader">
|
||||
<span><b>Names</b></span>
|
||||
</div><!--
|
||||
--><div class="row viewheader" id="timeheader">
|
||||
<div id="timelabels" style="padding-right: 256px;"></div>
|
||||
</div>
|
||||
<section class="names">
|
||||
<div id="names" style="padding-bottom: 256px;"></div>
|
||||
</section><!--
|
||||
--><section class="times">
|
||||
<div class="scroll">
|
||||
<div id="times"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="view.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
289
web/view.js
Normal file
289
web/view.js
Normal file
@ -0,0 +1,289 @@
|
||||
var data = null;
|
||||
|
||||
var names = document.querySelector('main #names')
|
||||
var times = document.querySelector('main #times')
|
||||
|
||||
function exportCSV() {
|
||||
|
||||
if (data == null) {
|
||||
alert('No data found.')
|
||||
return
|
||||
}
|
||||
|
||||
var startInput = document.querySelector('input#start')
|
||||
var endInput = document.querySelector('input#end')
|
||||
var roomInput = document.querySelector('input#room')
|
||||
|
||||
var startDate = new Date(startInput.value)
|
||||
var endDate = new Date(endInput.value)
|
||||
var roomRE = new RegExp(roomInput.value || '.*')
|
||||
|
||||
csv = '"ftracker-export",'
|
||||
days = []
|
||||
|
||||
var tc = new Date(startDate.getTime())
|
||||
tc.setHours(1,0,0,0)
|
||||
while (tc < endDate) {
|
||||
var isodate = tc.toISOString().split('T')[0]
|
||||
csv += ('"' + isodate + '",')
|
||||
days.push(isodate)
|
||||
tc.setDate(tc.getDate() + 1);
|
||||
}
|
||||
|
||||
csv = csv.replace(/,$/, '')
|
||||
|
||||
csv += '\n'
|
||||
|
||||
for (var [name, list] of Object.entries(data)) {
|
||||
|
||||
csv += '"' + name + '"'
|
||||
|
||||
for (day of days) {
|
||||
|
||||
csv += ',"'
|
||||
|
||||
daytexts = []
|
||||
|
||||
for (entry of list) {
|
||||
|
||||
if (entry.room.match(roomRE) == null)
|
||||
continue
|
||||
|
||||
var arrD = new Date(entry.arrival)
|
||||
var depD = entry.departure ? new Date(entry.departure) : new Date()
|
||||
|
||||
if (depD < startDate || arrD > endDate)
|
||||
continue
|
||||
|
||||
var [arrDay, arrT] = localISOTimeMinutes(arrD).split('T')
|
||||
var [depDay, depT] = localISOTimeMinutes(depD).split('T')
|
||||
|
||||
if ((arrDay == day) && (depDay == day)) {
|
||||
daytexts.push(arrT + '-' + depT + ' (' + entry.room + ')')
|
||||
} else if (arrDay == day) {
|
||||
daytexts.push(arrT + '-... (' + entry.room + ')')
|
||||
} else if (depDay == day) {
|
||||
daytexts.push('...-' + depT + ' (' + entry.room + ')')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
csv += daytexts.join('\n')
|
||||
|
||||
csv += '"'
|
||||
|
||||
}
|
||||
|
||||
csv += '\n'
|
||||
|
||||
}
|
||||
|
||||
var element = document.createElement('a');
|
||||
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(csv));
|
||||
element.setAttribute('download', 'ftracker-export.csv');
|
||||
element.style.display = 'none';
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
|
||||
}
|
||||
|
||||
function offerUserCheckout(event) {
|
||||
|
||||
var name = event.target.getAttribute('data-name')
|
||||
var room = event.target.textContent
|
||||
window.open(`/?departure=${room}&edittime=1&name=${name}`, '_blank').focus();
|
||||
|
||||
}
|
||||
|
||||
function renderData() {
|
||||
|
||||
if (data == null) {
|
||||
alert('No data found.')
|
||||
return
|
||||
}
|
||||
|
||||
names = document.querySelector('main #names')
|
||||
times = document.querySelector('main #times')
|
||||
|
||||
names.innerHTML = ''
|
||||
times.innerHTML = ''
|
||||
|
||||
var startInput = document.querySelector('input#start')
|
||||
var endInput = document.querySelector('input#end')
|
||||
var roomInput = document.querySelector('input#room')
|
||||
|
||||
var startDate = new Date(startInput.value)
|
||||
var endDate = new Date(endInput.value)
|
||||
var roomRE = new RegExp(roomInput.value || '.*')
|
||||
|
||||
// Clear everything below hours because that would lead to
|
||||
// misalignments with the day grid
|
||||
startDate.setMinutes(0,0,0)
|
||||
|
||||
var tc = new Date(startDate.getTime())
|
||||
var content = ''
|
||||
while (tc < endDate) {
|
||||
var h = tc.getHours()
|
||||
var t = (h == 0) ?
|
||||
'<b>'+tc.getDate()+'.'+(tc.getMonth()+1)+'.</b>' :
|
||||
h+':00'
|
||||
var left = ((tc - startDate) / (1000 * 60))
|
||||
content += '<span style="left:'+left+'px;">'+t+'</span>'
|
||||
tc.setTime(tc.getTime() + (60*60*1000));
|
||||
}
|
||||
var timeheader = document.getElementById('timelabels')
|
||||
timeheader.innerHTML = content
|
||||
|
||||
var viewwidth = ((endDate - startDate) / (1000 * 60))
|
||||
timeheader.style.width = viewwidth + 'px'
|
||||
times.style.width = viewwidth + 'px'
|
||||
|
||||
var rowCount = 0
|
||||
|
||||
for (var [name, list] of Object.entries(data)) {
|
||||
|
||||
var row = document.createElement('div')
|
||||
row.classList.add('row')
|
||||
|
||||
var rowHasBlock = false
|
||||
|
||||
for (entry of list) {
|
||||
|
||||
if (entry.room.match(roomRE) == null)
|
||||
continue
|
||||
|
||||
var arrD = new Date(entry.arrival)
|
||||
var depD = entry.departure ? new Date(entry.departure) : new Date()
|
||||
|
||||
if (depD < startDate || arrD > endDate)
|
||||
continue
|
||||
|
||||
rowHasBlock = true
|
||||
|
||||
// Minutes since start date / beginning
|
||||
var arr = (arrD - startDate) / (1000 * 60)
|
||||
var dep = (depD - startDate) / (1000 * 60)
|
||||
var dur = dep - arr
|
||||
|
||||
var block = document.createElement('span')
|
||||
block.innerHTML = entry.room
|
||||
block.style.left = arr + 'px' // 1px/min
|
||||
block.style.width = Math.max(0,(dur-14)) + 'px' // 1px/min
|
||||
if (entry.tested)
|
||||
block.classList.add('tested') // = 3G
|
||||
|
||||
if (dur > 60 * 24) {
|
||||
block.classList.add('implausible')
|
||||
block.setAttribute('data-name', name)
|
||||
block.addEventListener('click', offerUserCheckout);
|
||||
}
|
||||
|
||||
row.appendChild(block)
|
||||
|
||||
}
|
||||
|
||||
if (rowHasBlock) {
|
||||
var vname = name.replace(/-/g, ' ')
|
||||
names.innerHTML += '<div class="row"><span>'+vname+'</span></div>'
|
||||
times.appendChild(row)
|
||||
rowCount += 12
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//var viewheight = rowCount * 32;
|
||||
//times.style.height = viewheight + 'px'
|
||||
|
||||
var tw = document.querySelector('main .scroll')
|
||||
tw.scrollLeft = tw.scrollWidth
|
||||
|
||||
}
|
||||
|
||||
function saveData(rdata) {
|
||||
|
||||
data = rdata.reduce((acc, entry) => {
|
||||
var name = entry.name
|
||||
delete entry.name
|
||||
acc[name] = [...acc[name] || [], entry];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
console.log(data)
|
||||
renderData()
|
||||
|
||||
}
|
||||
|
||||
function submitCredentials() {
|
||||
var cp = document.querySelector('#credprompt')
|
||||
var user = cp.querySelector('#user').value
|
||||
var pass = cp.querySelector('#pass').value
|
||||
cp.remove()
|
||||
var auth = btoa(user + ":" + pass)
|
||||
localStorage.setItem('dataauth', auth)
|
||||
loadData()
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
|
||||
var auth = localStorage.getItem('dataauth')
|
||||
if (auth == null) {
|
||||
var prompt = document.createElement('div')
|
||||
prompt.id = 'credprompt'
|
||||
prompt.innerHTML = '<h1>Credentials Required</h1>\
|
||||
<input type="text" id="user" placeholder="username" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
|
||||
<input type="password" id="pass" placeholder="password" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
|
||||
<input type="submit" onclick="submitCredentials()">'
|
||||
document.body.appendChild(prompt)
|
||||
document.querySelector('#credprompt #user').focus()
|
||||
return // Abort load, wait for submit
|
||||
}
|
||||
|
||||
var headers = new Headers()
|
||||
headers.append('Authorization', 'Basic ' + auth)
|
||||
|
||||
var fetchopts = {
|
||||
method: 'GET',
|
||||
headers: headers
|
||||
}
|
||||
|
||||
fetch('/data', fetchopts)
|
||||
.then(res => {
|
||||
if (Math.floor(res.status / 100) == 2)
|
||||
return res.json()
|
||||
else
|
||||
localStorage.removeItem('dataauth')
|
||||
res.text().then(function (text) {
|
||||
alert(text)
|
||||
location.reload()
|
||||
})
|
||||
})
|
||||
.then(rdata => saveData(rdata))
|
||||
|
||||
}
|
||||
|
||||
function localISOTimeMinutes(date) {
|
||||
|
||||
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
|
||||
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
|
||||
|
||||
return localISOTime.split(':').slice(0,2).join(':')
|
||||
|
||||
}
|
||||
|
||||
var now = new Date()
|
||||
var startDate = new Date()
|
||||
startDate.setDate(now.getDate() - (4*7))
|
||||
startDate.setHours(0,0,0,0)
|
||||
document.querySelector('input#start').value = localISOTimeMinutes(startDate)
|
||||
document.querySelector('input#end').value = localISOTimeMinutes(now)
|
||||
|
||||
var scrollbox = document.querySelector('.scroll')
|
||||
var timehead = document.querySelector('#timeheader')
|
||||
var namebox = document.querySelector('section.names')
|
||||
scrollbox.onscroll = function() {
|
||||
timehead.scrollLeft = scrollbox.scrollLeft
|
||||
namebox.scrollTop = scrollbox.scrollTop
|
||||
}
|
||||
|
||||
loadData()
|
||||
@ -1,322 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>FTracker Data</title>
|
||||
<meta name="theme-color" content="#c50e1f">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
header {
|
||||
height: calc(38px - 16px);
|
||||
padding: 8px;
|
||||
}
|
||||
main {
|
||||
height: calc(100% - 38px);
|
||||
vertical-align: top;
|
||||
}
|
||||
main > section {
|
||||
height: calc(100% - 32px);
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
main > .viewheader {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
main > section.names, #nameheader {
|
||||
width: 127px;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid gray;
|
||||
font-weight: bold;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
main > section.times, #timeheader {
|
||||
width: calc(100% - 128px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.scroll {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: scroll;
|
||||
}
|
||||
.row, .row #timelabels {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
background-size: 60px 100%;
|
||||
}
|
||||
.names .row:nth-child(even) { background: #ddd; }
|
||||
.names .row:nth-child(odd) { background: #eee; }
|
||||
.times .row:nth-child(even) {
|
||||
background-image: linear-gradient(to right, #bbb 1px, #ddd 1px);
|
||||
}
|
||||
.times .row:nth-child(odd) {
|
||||
background-image: linear-gradient(to right, #bbb 1px, #eee 1px);
|
||||
}
|
||||
.row span {
|
||||
padding: 7px;
|
||||
display: inline-block;
|
||||
}
|
||||
.times span, #timeheader span {
|
||||
position: absolute;
|
||||
background: #c50e1f;
|
||||
color: #ddd;
|
||||
margin-right: 16px;
|
||||
}
|
||||
.viewheader.row {
|
||||
height: 30px;
|
||||
background: #ddd !important;
|
||||
border-top: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
}
|
||||
.viewheader span {
|
||||
background: none !important;
|
||||
color: #000 !important;
|
||||
padding-left: 4px;
|
||||
}
|
||||
#credprompt {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 95%;
|
||||
max-width: 320px;
|
||||
margin: auto;
|
||||
background: #ddd;
|
||||
box-shadow: 0 0 0 10000px rgba(0,0,0,.75);
|
||||
}
|
||||
#credprompt h1 {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
text-transform: uppercase;
|
||||
color: #eee;
|
||||
background: #c50e1f;
|
||||
text-align: center;
|
||||
}
|
||||
#credprompt input {
|
||||
display: block;
|
||||
border: none;
|
||||
margin: 16px auto;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
#credprompt input[type=text],
|
||||
#credprompt input[type=password] {
|
||||
color: #000;
|
||||
width: calc(100% - 64px);
|
||||
}
|
||||
#credprompt input[type=submit] {
|
||||
background: #c50e1f;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
Start: <input type="datetime-local" id="start" value="2020-12-01T00:00" onchange="renderData()">
|
||||
Ende: <input type="datetime-local" id="end" onchange="renderData()">
|
||||
Raum: <input type="text" id="room" placeholder=".* (regex)" onchange="renderData()">
|
||||
<input type="button" value="Log Out" style="float:right" onclick="localStorage.removeItem('dataauth'); location.reload()">
|
||||
</header>
|
||||
<main>
|
||||
<div class="row viewheader" id="nameheader">
|
||||
<span><b>Names</b></span>
|
||||
</div><!--
|
||||
--><div class="row viewheader" id="timeheader">
|
||||
<div id="timelabels" style="padding-right: 32px;"></div>
|
||||
</div>
|
||||
<section class="names">
|
||||
<div id="names" style="padding-bottom: 32px;"></div>
|
||||
</section><!--
|
||||
--><section class="times">
|
||||
<div class="scroll">
|
||||
<div id="times"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
var data = null;
|
||||
|
||||
var names = document.querySelector('main #names')
|
||||
var times = document.querySelector('main #times')
|
||||
|
||||
function renderData() {
|
||||
|
||||
if (data == null) {
|
||||
alert('No data found.')
|
||||
return
|
||||
}
|
||||
|
||||
names = document.querySelector('main #names')
|
||||
times = document.querySelector('main #times')
|
||||
|
||||
names.innerHTML = ''
|
||||
times.innerHTML = ''
|
||||
|
||||
var startInput = document.querySelector('input#start')
|
||||
var endInput = document.querySelector('input#end')
|
||||
var roomInput = document.querySelector('input#room')
|
||||
|
||||
var startDate = new Date(startInput.value)
|
||||
var endDate = new Date(endInput.value)
|
||||
var roomRE = new RegExp(roomInput.value || '.*')
|
||||
|
||||
var tc = new Date(startDate.getTime())
|
||||
var content = ''
|
||||
while (tc < endDate) {
|
||||
var h = tc.getHours()
|
||||
var t = (h == 0) ?
|
||||
'<b>'+tc.getDate()+'.'+(tc.getMonth()+1)+'.</b>' :
|
||||
h+':00'
|
||||
var left = ((tc - startDate) / (1000 * 60))
|
||||
content += '<span style="left:'+left+'px;">'+t+'</span>'
|
||||
tc.setTime(tc.getTime() + (60*60*1000));
|
||||
}
|
||||
var timeheader = document.getElementById('timelabels')
|
||||
timeheader.innerHTML = content
|
||||
|
||||
var viewwidth = ((endDate - startDate) / (1000 * 60))
|
||||
timeheader.style.width = viewwidth + 'px'
|
||||
times.style.width = viewwidth + 'px'
|
||||
|
||||
var rowCount = 0
|
||||
|
||||
for (var [name, list] of Object.entries(data)) {
|
||||
|
||||
var row = document.createElement('div')
|
||||
row.classList.add('row')
|
||||
|
||||
var rowHasRoom = false
|
||||
|
||||
for (entry of list) {
|
||||
|
||||
if (entry.room.match(roomRE) == null)
|
||||
continue
|
||||
|
||||
rowHasRoom = true
|
||||
|
||||
arrD = new Date(entry.arrival)
|
||||
depD = entry.departure ? new Date(entry.departure) : endDate
|
||||
|
||||
// Minutes since start date / beginning
|
||||
var arr = (arrD - startDate) / (1000 * 60)
|
||||
var dep = (depD - startDate) / (1000 * 60)
|
||||
var dur = dep - arr
|
||||
|
||||
var block = document.createElement('span')
|
||||
block.innerHTML = entry.room
|
||||
block.style.left = arr + 'px' // 1px/min
|
||||
block.style.width = (dur-14) + 'px' // 1px/min
|
||||
|
||||
row.appendChild(block)
|
||||
|
||||
}
|
||||
|
||||
if (rowHasRoom) {
|
||||
var vname = name.replace('-', ' ')
|
||||
names.innerHTML += '<div class="row"><span>'+vname+'</span></div>'
|
||||
times.appendChild(row)
|
||||
rowCount += 12
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//var viewheight = rowCount * 32;
|
||||
//times.style.height = viewheight + 'px'
|
||||
|
||||
var tw = document.querySelector('main .scroll')
|
||||
tw.scrollLeft = tw.scrollWidth
|
||||
|
||||
}
|
||||
|
||||
function saveData(rdata) {
|
||||
|
||||
data = rdata.reduce((acc, entry) => {
|
||||
var name = entry.name
|
||||
delete entry.name
|
||||
acc[name] = [...acc[name] || [], entry];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
console.log(data)
|
||||
renderData()
|
||||
|
||||
}
|
||||
|
||||
function submitCredentials() {
|
||||
var cp = document.querySelector('#credprompt')
|
||||
var user = cp.querySelector('#user').value
|
||||
var pass = cp.querySelector('#pass').value
|
||||
cp.remove()
|
||||
var auth = btoa(user + ":" + pass)
|
||||
localStorage.setItem('dataauth', auth)
|
||||
loadData()
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
|
||||
var auth = localStorage.getItem('dataauth')
|
||||
if (auth == null) {
|
||||
var prompt = document.createElement('div')
|
||||
prompt.id = 'credprompt'
|
||||
prompt.innerHTML = '<h1>Credentials Required</h1>\
|
||||
<input type="text" id="user" placeholder="username" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
|
||||
<input type="password" id="pass" placeholder="password" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
|
||||
<input type="submit" onclick="submitCredentials()">'
|
||||
document.body.appendChild(prompt)
|
||||
document.querySelector('#credprompt #user').focus()
|
||||
return // Abort load, wait for submit
|
||||
}
|
||||
|
||||
var headers = new Headers()
|
||||
headers.append('Authorization', 'Basic ' + auth)
|
||||
|
||||
var fetchopts = {
|
||||
method: 'GET',
|
||||
headers: headers
|
||||
}
|
||||
|
||||
fetch('/data', fetchopts)
|
||||
.then(res => {
|
||||
if (Math.floor(res.status / 100) == 2)
|
||||
return res.json()
|
||||
else
|
||||
localStorage.removeItem('dataauth')
|
||||
res.text().then(function (text) {
|
||||
alert(text)
|
||||
location.reload()
|
||||
})
|
||||
})
|
||||
.then(rdata => saveData(rdata))
|
||||
|
||||
}
|
||||
|
||||
function localISOTimeMinutes(date) {
|
||||
|
||||
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
|
||||
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
|
||||
|
||||
return localISOTime.split(':').slice(0,2).join(':')
|
||||
|
||||
}
|
||||
|
||||
document.querySelector('input#end').value = localISOTimeMinutes(new Date())
|
||||
|
||||
var scrollbox = document.querySelector('.scroll')
|
||||
var timehead = document.querySelector('#timeheader')
|
||||
var namebox = document.querySelector('section.names')
|
||||
scrollbox.onscroll = function() {
|
||||
timehead.scrollLeft = scrollbox.scrollLeft
|
||||
namebox.scrollTop = scrollbox.scrollTop
|
||||
}
|
||||
|
||||
loadData()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user