Compare commits

...

77 Commits
0.1.0 ... main

Author SHA1 Message Date
95908c9290 doc: Add issues: grouped measures and measure visibility. 2021-07-19 08:03:12 -05:00
3c9c24f30b api: Consolidate AWS Secret usage to one secret per environment. 2021-07-05 15:19:04 -05:00
16c7852972 Update package version to 0.11.0 2021-07-05 11:41:43 -05:00
0c7ab9524d api: Ignore DEV and PROD config files (may contain sensitive info). 2021-07-05 11:41:06 -05:00
7fb26bab97 operations: Expose the update version script in the main Makefile. 2021-07-05 11:39:50 -05:00
ca70773a8c api: Update fiber_orm dependency to fix a bug in parsing PostgreSQL timestamps. 2021-07-05 11:39:23 -05:00
a0f9670688 api: Use the standard config pattern for KNOWN_ORIGINS. 2021-07-05 11:35:35 -05:00
2fd45ac35c api: Refresh the API CLI wrapper script. 2021-07-05 11:35:03 -05:00
3844e97c48 api: Updates to Makefile and configuration files.
- Re-organized and documented make targets.
- Parameterized the make targets to more cleanly support multiple
  development scenarios.
- Documented the different development scenarios in a README.
- Standardized the host port used when running locally.
- Updated DEV and PROD database config files to match current practice.
- Renamed `personal_measure_api.config.prod.json` to
  `personal_measure_api.config.docker.json` to more accurately reflect
  that this is the config used when building the docker image
  (regardless of which env it ends up in).
2021-07-05 11:31:30 -05:00
3416d2b85b Update package version to 0.10.0 2021-07-05 02:01:16 -05:00
f29b1a0967 operations: Update primary Makefile to reflect new API ECS-based build and deploy process. 2021-07-05 02:00:49 -05:00
e3f214d0da api: Update API to support Options requests for CORS. 2021-07-05 01:59:46 -05:00
c987d66504 api: Update Dockerfile and Makefile to support building and pushing to ECR. 2021-07-05 01:59:16 -05:00
bc06fc54bb operations: Complete migration to AWS ECS. 2021-07-05 01:57:39 -05:00
99a4c1fc94 web: Update environment configurations for jdb-software. 2021-07-05 00:17:17 -05:00
87ce9cc4d4 operations: WIP continuing definition for ECS-based API deployment. 2021-07-03 03:36:41 -05:00
c2c4c8473d Update Makefile to disable obsolete API deployment flow. 2021-07-03 03:35:59 -05:00
bb89f519e0 operations: WIP moving API to run as an ECS task. 2021-07-03 01:30:51 -05:00
20e0a0b09e api: Clean up Dockerfile, rebase onto Nim 1.4.8. 2021-07-03 01:30:26 -05:00
1449e1ffdd api: Updates for Nim 1.4.x. 2021-07-03 01:26:56 -05:00
526419afb3 api: Change dependencies from jdb-labs.com -> jdb-software.com 2021-07-03 01:26:24 -05:00
327c64f45a web: Update dependencies. 2021-03-07 17:53:32 -06:00
06e3bb5ea3 Switch to servor instead of the build-in vue-cli-service server for development tools. 2020-07-06 18:37:13 -05:00
6a77efe2cf Update package version to 0.9.0 2020-03-15 17:19:14 -05:00
a1dc067d17 web: Open time format reference link in a new window. 2020-03-15 17:18:57 -05:00
23600cedee web: Adjust to new API URLs, implement update for Measure. 2020-03-15 17:18:40 -05:00
c032bf10e7 api: Refactor measurements URLs, add Measure update endpoint. 2020-03-15 17:17:59 -05:00
f4f695ce80 web: WIP edit measure configuration. 2020-03-14 22:48:30 -05:00
c685f55d15 web: Add basic detail view of text measures. 2020-03-14 22:46:46 -05:00
e9de9aebf3 web: Add timestamp display format to measure configuration. 2020-03-14 22:45:59 -05:00
cf69ff2fa1 Make the measure filter case-insensitive. 2020-03-14 16:22:22 -05:00
baf37698b3 Issue 002: Delete functionality for measures. 2020-03-14 16:21:57 -05:00
3dd7169b8b WIP Adding support for Text entry measurements (renamed from List). 2020-03-13 23:09:01 -05:00
53a11b9e57 Add delete button for Measure (UI only, not hooked up). 2020-03-13 23:08:21 -05:00
ff17d9bf7a Move timestamp comparator function into shared util module. 2020-03-13 23:07:06 -05:00
9c9fe8786c Issue tracking: add things and start 004. 2020-03-13 23:05:31 -05:00
b64a3996e5 Add simple issue tracking system. 2020-03-13 17:12:50 -05:00
c8abfd00d0 api: Add dev database configuration. 2020-02-17 00:05:26 -06:00
b4b125d750 Update package version to 0.8.0 2020-02-16 23:55:25 -06:00
826f0eaa73 web: Add support for decimal measure values. 2020-02-16 23:21:56 -06:00
adddef3188 api: Support for decimal values in measures. 2020-02-16 23:20:02 -06:00
3e2faf9554 Update dependencies. 2020-02-16 21:09:25 -06:00
744ad9211b Use straight lines on simple measure graphs. 2020-02-16 21:09:14 -06:00
35a116abbb Fix insert/update logic of the measurements store. 2020-02-16 21:08:05 -06:00
4cb5b8d814 Add version tag in HTML, fix lint error. 2020-02-09 05:09:49 -06:00
efb86cf6ce Update package version to 0.7.0 2020-02-09 04:25:21 -06:00
c6863293c5 api: User released version of fiber_orm (not local). 2020-02-09 04:25:09 -06:00
ce582383c3 api: Change root application path to '/v0' instead of '/api'. 2020-02-09 04:25:09 -06:00
31053c1014 Add clean target to Makefile. 2020-02-09 04:25:09 -06:00
f5b891b966 update-version.sh should include changes to package-lock.json. 2020-02-09 04:18:38 -06:00
74b8a42d29 web: Sort data in simple measure graphs. 2020-02-09 04:16:26 -06:00
8af6c65c9b api: Add support for necessary CORS headers. 2020-02-09 04:16:15 -06:00
e14097117f api: Add nginx configuration for OPTIONS CORS support. 2020-02-09 04:15:43 -06:00
c6d8d14a1f Add update-version.sh convenience script. 2020-02-09 04:10:10 -06:00
ff3c1cf04e Clean up logging service. 2020-02-09 03:41:22 -06:00
8ac1cdf476 Fix environment-specific builds. 2020-02-09 03:40:49 -06:00
c28eb7b240 Update operations documentation. 2020-02-09 03:16:03 -06:00
31326d40c8 web: Update dependencies (npm audit fix). 2020-02-09 03:16:03 -06:00
716f09681c Move Terraform state into S3 (using DynamoDB for locking). 2020-02-09 03:16:03 -06:00
ead77534ce api: Extract database common code into its own library (fiber-orm). 2020-02-09 03:15:58 -06:00
c5daa76102 web: Parameterize build process with env-dependent config files. 2020-02-09 00:30:38 -06:00
cfd5463b4d Add CDN cache invalidation to deploy scripts. 2019-09-25 10:48:46 -05:00
5c81d756df api: Refactor so all endpoints are CORS-aware. 2019-09-25 08:15:08 -05:00
cf60793395 api: WIP CORS supoport 2019-09-24 22:43:23 -05:00
0a8f701c3c Created terraform configuration to manage AWS infrastructure. 2019-09-24 22:40:08 -05:00
a4b798cec4 Bump version. 2019-05-19 18:31:33 -05:00
5f257e9b4a Target dev environment by default for deploys. 2019-05-19 18:30:53 -05:00
7e5827a7a2 api: Add Cache-Control header. 2019-05-19 18:28:38 -05:00
793dbcc611 Add support for parsing Postgres dates that only use one or two millisecond digits. 2019-05-19 18:28:22 -05:00
d37dc77490 Bump version. 2019-05-19 01:29:46 -05:00
ffa7e1a4de Documentation around new deployed environments. 2019-05-19 01:28:22 -05:00
30ced3ecfd Allow targeting different environments with deply. Add version string on the web App component. 2019-05-19 01:28:02 -05:00
4bc8c00c49 api: Update systemd [Install] so that pmapi starts with the ssytem. 2019-05-19 01:26:22 -05:00
e33ba9707c web: Move the .env.prod to .env.production (where it belongs). 2019-05-19 01:25:57 -05:00
Jonathan Bernard
9af4af6c5d Added deployment documentation. 2019-05-18 18:48:12 -05:00
Jonathan Bernard
d88689ee31 Add build and deploy targets to top-level Makefile. 2019-05-18 17:45:43 -05:00
Jonathan Bernard
1d544dad0b Bump version. 2019-05-18 13:28:02 -05:00
100 changed files with 4827 additions and 3371 deletions

8
.gitignore vendored
View File

@ -3,6 +3,8 @@ api/personal_measure_api
api/postgres.container.id api/postgres.container.id
api/src/main/nim/personal_measure_api api/src/main/nim/personal_measure_api
api/src/main/nim/personal_measure_apipkg/db api/src/main/nim/personal_measure_apipkg/db
api/personal_measure_api.config.dev.json
api/personal_measure_api.config.prod.json
.DS_Store .DS_Store
node_modules node_modules
@ -26,3 +28,9 @@ yarn-error.log*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Terrform files
.terraform/
# API Testing Files
api/temp/

View File

@ -1,15 +1,32 @@
VERSION=`git describe --always` VERSION:=$(shell git describe --always)
TARGET_ENV ?= dev
build: build-api build-web build: dist/personal-measure-api.tar.gz dist/personal-measure-web.tar.gz
build-api: clean:
-rm -r dist
-rm -r web/dist
-docker container prune
-docker image prune
update-version:
operations/update-version.sh
dist/personal-measure-web.tar.gz:
-mkdir dist -mkdir dist
make -C api personal_measure_api TARGET_ENV=$(TARGET_ENV) make -C web build
tar czf dist/personal-measure-api-${VERSION}.tar.gz -C api personal_measure_api
build-web:
-mkdir dist
(cd web && npm run build)
tar czf dist/personal-measure-web-${VERSION}.tar.gz -C web/dist . tar czf dist/personal-measure-web-${VERSION}.tar.gz -C web/dist .
cp dist/personal-measure-web-${VERSION}.tar.gz dist/personal-measure-web.tar.gz
#deploy-api: build-api deploy-api:
make -C api personal_measure_api-image push-image
cd operations/terraform && terraform apply -target module.${TARGET_ENV}_env.aws_ecs_task_definition.pmapi -target module.${TARGET_ENV}_env.aws_ecs_service.pmapi
deploy-web: dist/personal-measure-web.tar.gz
mkdir -p temp-deploy/personal-measure-web-${VERSION}
tar xzf dist/personal-measure-web-${VERSION}.tar.gz -C temp-deploy/personal-measure-web-${VERSION}
aws s3 sync temp-deploy/personal-measure-web-${VERSION} s3://pm.jdb-software.com/$(TARGET_ENV)/webroot
TARGET_ENV=${TARGET_ENV} operations/invalidate-cdn-cache.sh
rm -r temp-deploy
deploy: deploy-api deploy-web

View File

@ -1,26 +1,22 @@
FROM 063932952339.dkr.ecr.us-west-2.amazonaws.com/nim-alpine AS build FROM 063932952339.dkr.ecr.us-west-2.amazonaws.com/alpine-nim:nim-1.4.8 AS build
MAINTAINER jonathan@jdbernard.com MAINTAINER jonathan@jdbernard.com
# TODO: install db_migrate so we can use it below
# RUN nimble install https://git.jdb-labs.com/jdb/db-migrate.git
#RUN apt-get install -y libssl-dev
COPY personal_measure_api.nimble /pm-api/ COPY personal_measure_api.nimble /pm-api/
COPY src /pm-api/src COPY src /pm-api/src
WORKDIR /pm-api WORKDIR /pm-api
RUN nimble build -y RUN nimble build -y
FROM alpine FROM alpine
#RUN apt-get install -y postgresql-client EXPOSE 80
RUN apk -v --update add --no-cache \ RUN apk -v --update add --no-cache \
ca-certificates \ ca-certificates \
libressl2.7-libssl \ libcrypto1.1 \
libressl2.7-libcrypto \ libssl1.1 \
pcre \ pcre \
postgresql-client postgresql-client
COPY --from=build /pm-api/personal_measure_api / COPY --from=build /pm-api/personal_measure_api /
COPY personal_measure_api.config.prod.json /personal_measure_api.config.json COPY personal_measure_api.config.docker.json /personal_measure_api.config.json
CMD ["/personal_measure_api", "serve"] CMD ["/personal_measure_api", "serve"]
# TODO: replace the above with something like: # TODO: replace the above with something like:

View File

@ -1,31 +1,123 @@
PGSQL_CONTAINER_ID=`cat postgres.container.id` PGSQL_CONTAINER_ID=`cat postgres.container.id`
DB_NAME="personal_measure"
SOURCES=$(wildcard src/main/nim/*.nim) $(wildcard src/main/nim/personal_measure_apipkg/*.nim) SOURCES=$(wildcard src/main/nim/*.nim) $(wildcard src/main/nim/personal_measure_apipkg/*.nim)
serve: personal_measure_api start-postgres # Variables that can be overriden
# -------------------------------
# AWS Account URL for the ECR repository
ECR_ACCOUNT_URL ?= 063932952339.dkr.ecr.us-west-2.amazonaws.com
# The version number that will be tagged the container image. You might want to
# override this when doing local development to create local versions that are
# reflect changes not yet committed.
VERSION ?= `git describe`
# The port on the host machine (not the container)
PORT ?= 8100
# The name of the database (used then creating a local Postgres container)
DB_NAME ?= personal_measure
# The database connection string. You would change this to point the API at a
# different database server (default is the local Postgres container).
DB_CONN_STRING ?= host=localhost dbname=$(DB_NAME) user=postgres password=password port=5500
# The API authentication secret (used for hashing passwords, etc.)
AUTH_SECRET ?= 123abc
default: start-postgres serve-docker
# Building and deploying the API container image
# ----------------------------------------------
personal_measure_api-image: $(SOURCES)
# Build the container image.
docker image build -t $(ECR_ACCOUNT_URL)/personal_measure_api:$(VERSION) .
push-image: personal_measure_api-image
# Push the container image to the private AWS ECR
docker push $(ECR_ACCOUNT_URL)/personal_measure_api:$(VERSION)
# Running the API locally on bare metal
# -------------------------------------
personal_measure_api: $(SOURCES)
# Build the API
nimble build
serve: personal_measure_api
# Run the API on this machine. Note that configuration is taken by default
# from the `personal_measure_api.config.json` file, but environment variables
# specified when running make can be used to override these (to change the
# DB_CONN_STRING, for example).
./personal_measure_api serve ./personal_measure_api serve
# Running the API locally in a container
# --------------------------------------
serve-docker: personal_measure_api-image
# Run the API in a docker container. Note that the configuration loaded into
# the Docker container defines very little of the actual configuration as
# environment variables are used in the deployed environments. Accordingly,
# we must specify them explicitly here.
docker run \
-e AUTH_SECRET=$(AUTH_SECRET) \
-e PORT=80 \
-e "DB_CONN_STRING=$(DB_CONN_STRING)" \
-e 'KNOWN_ORIGINS=["https://curl.localhost"]' \
-p 127.0.0.1:$(PORT):80/tcp \
$(ECR_ACCOUNT_URL)/personal_measure_api:$(VERSION)
# Managing Postgres in a local container
# --------------------------------------
#
# This supports local development on this machine. These commands rely on a
# file named `postgres.container.id` to track the existing and ID of the
# local Postgres instance.
postgres.container.id: postgres.container.id:
# This creates a new local Postegres container and initializes the PM API
# database scheme.
docker run --name postgres-$(DB_NAME) -e POSTGRES_PASSWORD=password -p 5500:5432 -d postgres > postgres.container.id docker run --name postgres-$(DB_NAME) -e POSTGRES_PASSWORD=password -p 5500:5432 -d postgres > postgres.container.id
sleep 5 sleep 5
PGPASSWORD=password psql -p 5500 -U postgres -h localhost -c "CREATE DATABASE $(DB_NAME);" PGPASSWORD=password psql -p 5500 -U postgres -h localhost -c "CREATE DATABASE $(DB_NAME);"
db_migrate up -c database-local.json db_migrate up -c database-local.json
start-postgres: postgres.container.id start-postgres: postgres.container.id
# Start the existing local Postgres container
docker start $(PGSQL_CONTAINER_ID) docker start $(PGSQL_CONTAINER_ID)
sleep 1 sleep 1
db_migrate up -c database-local.json db_migrate up -c database-local.json
stop-postgres: postgres.container.id stop-postgres: postgres.container.id
# Stop the existing local Postgres container
docker stop $(PGSQL_CONTAINER_ID) docker stop $(PGSQL_CONTAINER_ID)
delete-postgres-container: delete-postgres-container:
# Delete the local Postgres container. Note that this will destroy any data
# in this database instance.
-docker stop $(PGSQL_CONTAINER_ID) -docker stop $(PGSQL_CONTAINER_ID)
docker container rm $(PGSQL_CONTAINER_ID) docker container rm $(PGSQL_CONTAINER_ID)
rm postgres.container.id rm postgres.container.id
connect: connect-postgres:
# Connect to the Postgres instance running in the local container
PGPASSWORD=password psql -p 5500 -U postgres -h localhost ${DB_NAME} PGPASSWORD=password psql -p 5500 -U postgres -h localhost ${DB_NAME}
personal_measure_api: $(SOURCES)
nimble build # Utility
# -------
ecr-auth:
# Authenticate docker to the AWS private elastic container repository.
aws ecr get-login-password --region us-west-2 | docker login --username AWS --password-stdin 063932952339.dkr.ecr.us-west-2.amazonaws.com
echo-vars:
@echo \
" ECR_ACCOUNT_URL=$(ECR_ACCOUNT_URL)\n" \
"VERSION=$(VERSION)\n" \
"PORT=$(PORT)\n" \
"DB_NAME=$(DB_NAME)\n" \
"DB_CONN_STRING=$(DB_CONN_STRING)\n" \
"AUTH_SECRET=$(AUTH_SECRET)\n"

30
api/README.md Normal file
View File

@ -0,0 +1,30 @@
## Local Development
Examples of different local development & testing scenarios:
- Bare-metal API server, local Postgres container
make start-postgres
make serve
- Bare-metal API server, different Postgres server
DB_CONN_STRING="host=<db-hostname> user=pmapi password=<pwd>" make serve
- Docker API Server, local Postgres container
make start-postgres
VERSION=0.X.0-alpha make serve-docker
- Docker API server, different Postgres server
DB_CONN_STRING="host=<db-hostname> user=pmapi password=<pwd>" \
VERSION=0.X.0-alpha \
make serve-docker
All of the available `make` targets are documented inline; see the
[Makefile](./Makefile) for more details.
### Using the API CLI wrapper
The API CLI wrapper

5
api/database-dev.json Normal file
View File

@ -0,0 +1,5 @@
{
"driver": "postgres",
"connectionString": "host=localhost port=5432 dbname=personal_measure_dev user=pmapi",
"sqlDir": "src/main/sql/migrations"
}

View File

@ -1,5 +1,5 @@
{ {
"driver": "postgres", "driver": "postgres",
"connectionString": "host=localhost port=5999 dbname=personal_measure user=postgres", "connectionString": "host=localhost port=5432 dbname=personal_measure user=pmapi",
"sqlDir": "src/main/sql/migrations" "sqlDir": "src/main/sql/migrations"
} }

View File

@ -1,5 +1,4 @@
{ {
"debug":false, "debug":false,
"port":80,
"pwdCost":11 "pwdCost":11
} }

View File

@ -2,6 +2,7 @@
"authSecret":"bifekHuffIs3", "authSecret":"bifekHuffIs3",
"dbConnString":"host=localhost port=5500 dbname=personal_measure user=postgres password=password", "dbConnString":"host=localhost port=5500 dbname=personal_measure user=postgres password=password",
"debug":true, "debug":true,
"port":8081, "port":8100,
"pwdCost":11 "pwdCost":11,
"knownOrigins": [ "https://curl.localhost" ]
} }

View File

@ -2,7 +2,7 @@
include "src/main/nim/personal_measure_apipkg/version.nim" include "src/main/nim/personal_measure_apipkg/version.nim"
version = PM_API_VERSION version = "0.11.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "JDB\'s Personal Measures API" description = "JDB\'s Personal Measures API"
license = "MIT" license = "MIT"
@ -14,7 +14,8 @@ skipExt = @["nim"]
# Dependencies # Dependencies
requires @["nim >= 0.19.4", "bcrypt", "docopt >= 0.6.8", "isaac >= 0.1.3", requires @["nim >= 0.19.4", "bcrypt", "docopt >= 0.6.8", "isaac >= 0.1.3",
"jester >= 0.4.1", "jwt", "tempfile", "uuids >= 0.1.10" ] "jester >= 0.4.3", "jwt", "tempfile", "uuids >= 0.1.10" ]
requires "https://git.jdb-labs.com/jdb/nim-cli-utils.git >= 0.6.3" requires "https://git.jdb-software.com/jdb/nim-cli-utils.git >= 0.6.3"
requires "https://git.jdb-labs.com/jdb/nim-time-utils.git >= 0.5.0" requires "https://git.jdb-software.com/jdb/nim-time-utils.git >= 0.5.2"
requires "https://git.jdb-software.com/jdb-software/fiber-orm-nim.git >= 0.3.2"

View File

@ -27,7 +27,7 @@ proc loadConfig*(args: Table[string, docopt.Value] = initTable[string, docopt.Va
try: json = parseFile(filePath) try: json = parseFile(filePath)
except: except:
json = %DEFAULT_CONFIG json = %DEFAULT_CONFIG
if not existsFile(filePath): if not fileExists(filePath):
info "created new configuration file \"" & filePath & "\"" info "created new configuration file \"" & filePath & "\""
filePath.writeFile($json) filePath.writeFile($json)
else: else:
@ -40,8 +40,9 @@ proc loadConfig*(args: Table[string, docopt.Value] = initTable[string, docopt.Va
authSecret: cfg.getVal("auth-secret"), authSecret: cfg.getVal("auth-secret"),
dbConnString: cfg.getVal("db-conn-string"), dbConnString: cfg.getVal("db-conn-string"),
debug: "true".startsWith(cfg.getVal("debug", "false").toLower()), debug: "true".startsWith(cfg.getVal("debug", "false").toLower()),
port: parseInt(cfg.getVal("port", "8080")), port: parseInt(cfg.getVal("port", "8100")),
pwdCost: cast[int8](parseInt(cfg.getVal("pwd-cost", "11")))) pwdCost: cast[int8](parseInt(cfg.getVal("pwd-cost", "11"))),
knownOrigins: cfg.getVal("known-origins")[1..^2].split(',').mapIt(it[1..^2]))
proc initContext(args: Table[string, docopt.Value]): PMApiContext = proc initContext(args: Table[string, docopt.Value]): PMApiContext =
@ -109,6 +110,6 @@ Options:
if args["serve"]: start(ctx) if args["serve"]: start(ctx)
except: except:
fatal "pit: " & getCurrentExceptionMsg() fatal "personal_measure_api: " & getCurrentExceptionMsg()
#raise getCurrentException() #raise getCurrentException()
quit(QuitFailure) quit(QuitFailure)

View File

@ -1,7 +1,9 @@
import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils, import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils,
strutils, times, uuids times, uuids
from httpcore import HttpMethod
from unicode import capitalize from unicode import capitalize
import timeutils except `<` import strutils except capitalize
import timeutils
import ./db, ./configuration, ./models, ./service, ./version import ./db, ./configuration, ./models, ./service, ./version
@ -20,7 +22,7 @@ proc newSession*(user: User): Session =
template halt(code: HttpCode, template halt(code: HttpCode,
headers: RawHeaders, headers: RawHeaders,
content: string): typed = content: string) =
## Immediately replies with the specified request. This means any further ## Immediately replies with the specified request. This means any further
## code will not be executed after calling this template in the current ## code will not be executed after calling this template in the current
## route. ## route.
@ -32,21 +34,65 @@ template halt(code: HttpCode,
result.matched = true result.matched = true
break allRoutes break allRoutes
template jsonResp(code: HttpCode, details: string = "", headers: RawHeaders = @{:} ) = template jsonResp(code: HttpCode, body: string = "", headersToSend: RawHeaders = @{:} ) =
let reqOrigin =
if request.headers.hasKey("Origin"): $(request.headers["Origin"])
else: ""
let corsHeaders =
if ctx.cfg.knownOrigins.contains(reqOrigin):
@{
"Access-Control-Allow-Origin": reqOrigin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": $(request.reqMethod),
"Access-Control-Allow-Headers": "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
}
else: @{:}
halt( halt(
code, code,
headers & @{"Content-Type": JSON}, headersToSend & corsHeaders & @{
"Content-Type": JSON,
"Cache-Control": "no-cache"
},
body
)
template optionsResp(allowedMethods: seq[HttpMethod]) =
let reqOrigin =
if request.headers.hasKey("Origin"): $(request.headers["Origin"])
else: ""
let corsHeaders =
if ctx.cfg.knownOrigins.contains(reqOrigin):
@{
"Access-Control-Allow-Origin": reqOrigin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": allowedMethods.mapIt($it).join(", "),
"Access-Control-Allow-Headers": "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
}
else: @{:}
halt(
Http200,
corsHeaders,
""
)
template jsonResp(body: string) = jsonResp(Http200, body)
template statusResp(code: HttpCode, details: string = "", headersToSend: RawHeaders = @{:} ) =
jsonResp(
code,
$(%* { $(%* {
"statusCode": code.int, "statusCode": code.int,
"status": $code, "status": $code,
"details": details "details": details
}) }),
) headersToSend)
template json500Resp(ex: ref Exception, details: string = ""): void =
when not defined(release): debug ex.getStackTrace()
error details & ":\n" & ex.msg
jsonResp(Http500)
# internal JSON parsing utils # internal JSON parsing utils
proc getIfExists(n: JsonNode, key: string): JsonNode = proc getIfExists(n: JsonNode, key: string): JsonNode =
@ -75,7 +121,7 @@ proc fromJWT*(ctx: PMApiContext, strTok: string): Session =
## Validate a given JWT and extract the session data. ## Validate a given JWT and extract the session data.
let jwt = toJWT(strTok) let jwt = toJWT(strTok)
var secret = ctx.cfg.authSecret var secret = ctx.cfg.authSecret
if not jwt.verify(secret): raiseEx "Unable to verify auth token." if not jwt.verify(secret, HS256): raiseEx "Unable to verify auth token."
jwt.verifyTimeClaims() jwt.verifyTimeClaims()
# Find the user record (if authenticated) # Find the user record (if authenticated)
@ -173,10 +219,10 @@ template checkAuth(requiresAdmin = false) =
try: session = extractSession(ctx, request) try: session = extractSession(ctx, request)
except: except:
debug "Auth failed: " & getCurrentExceptionMsg() debug "Auth failed: " & getCurrentExceptionMsg()
jsonResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"}) statusResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"})
if requiresAdmin and not session.user.isAdmin: if requiresAdmin and not session.user.isAdmin:
jsonResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"}) statusResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"})
proc start*(ctx: PMApiContext): void = proc start*(ctx: PMApiContext): void =
@ -186,12 +232,16 @@ proc start*(ctx: PMApiContext): void =
settings: settings:
port = Port(ctx.cfg.port) port = Port(ctx.cfg.port)
appName = "/api" appName = "/v0"
routes: routes:
options "/version": optionsResp(@[HttpGet])
get "/version": get "/version":
resp($(%("personal_measure_api v" & PM_API_VERSION)), JSON) jsonResp($(%("personal_measure_api v" & PM_API_VERSION)))
options "/auth-token": optionsResp(@[HttpPost])
post "/auth-token": post "/auth-token":
@ -200,9 +250,11 @@ proc start*(ctx: PMApiContext): void =
let email = jsonBody.getOrFail("email").getStr let email = jsonBody.getOrFail("email").getStr
let pwd = jsonBody.getOrFail("password").getStr let pwd = jsonBody.getOrFail("password").getStr
let authToken = makeAuthToken(ctx, email, pwd) let authToken = makeAuthToken(ctx, email, pwd)
resp($(%authToken), JSON) jsonResp($(%authToken))
except JsonParsingError: jsonResp(Http400, getCurrentExceptionMsg()) except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except: jsonResp(Http401, getCurrentExceptionMsg()) except: statusResp(Http401, getCurrentExceptionMsg())
options "/change-pwd": optionsResp(@[HttpPost])
post "/change-pwd": post "/change-pwd":
checkAuth() checkAuth()
@ -215,15 +267,17 @@ proc start*(ctx: PMApiContext): void =
let newHash = hashWithSalt(jsonBody.getOrFail("newPassword").getStr, session.user.salt) let newHash = hashWithSalt(jsonBody.getOrFail("newPassword").getStr, session.user.salt)
session.user.hashedPwd = newHash.hash session.user.hashedPwd = newHash.hash
if ctx.db.updateUser(session.user): jsonResp(Http200) if ctx.db.updateUser(session.user): statusResp(Http200)
else: jsonResp(Http500, "unable to change pwd") else: statusResp(Http500, "unable to change pwd")
except JsonParsingError: jsonResp(Http400, getCurrentExceptionMsg()) except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: jsonResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except AuthError: jsonResp(Http401, getCurrentExceptionMsg()) except AuthError: statusResp(Http401, getCurrentExceptionMsg())
except: except:
error "internal error changing password: " & getCurrentExceptionMsg() error "internal error changing password: " & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
options "/change-pwd/@userId": optionsResp(@[HttpPost])
post "/change-pwd/@userId": post "/change-pwd/@userId":
checkAuth(true) checkAuth(true)
@ -234,22 +288,24 @@ proc start*(ctx: PMApiContext): void =
var user = ctx.db.getUser(parseUUID(@"userId")) var user = ctx.db.getUser(parseUUID(@"userId"))
let newHash = hashWithSalt(jsonBody.getOrFail("newPassword").getStr, user.salt) let newHash = hashWithSalt(jsonBody.getOrFail("newPassword").getStr, user.salt)
user.hashedPwd = newHash.hash user.hashedPwd = newHash.hash
if ctx.db.updateUser(user): jsonResp(Http200) if ctx.db.updateUser(user): statusResp(Http200)
else: jsonResp(Http500, "unable to change pwd") else: statusResp(Http500, "unable to change pwd")
except ValueError: jsonResp(Http400, "invalid UUID") except ValueError: statusResp(Http400, "invalid UUID")
except JsonParsingError: jsonResp(Http400, getCurrentExceptionMsg()) except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: jsonResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except AuthError: jsonResp(Http401, getCurrentExceptionMsg()) except AuthError: statusResp(Http401, getCurrentExceptionMsg())
except NotFoundError: jsonResp(Http404, "no such user") except NotFoundError: statusResp(Http404, "no such user")
except: except:
error "internal error changing password: " & getCurrentExceptionMsg() error "internal error changing password: " & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
options "/user": optionsResp(@[HttpGet, HttpPut])
get "/user": get "/user":
checkAuth() checkAuth()
resp(Http200, $(%session.user), JSON) jsonResp($(%session.user))
put "/user": put "/user":
checkAuth() checkAuth()
@ -262,18 +318,20 @@ proc start*(ctx: PMApiContext): void =
if jsonBody.hasKey("displayName"): if jsonBody.hasKey("displayName"):
updatedUser.displayName = jsonBody["displayName"].getStr() updatedUser.displayName = jsonBody["displayName"].getStr()
jsonResp(Http200, $(%ctx.db.updateUser(updatedUser))) statusResp(Http200, $(%ctx.db.updateUser(updatedUser)))
except JsonParsingError: jsonResp(Http400, getCurrentExceptionMsg()) except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: jsonResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except: except:
error "Could not update user information:\n\t" & getCurrentExceptionMsg() error "Could not update user information:\n\t" & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
options "/users": optionsResp(@[HttpGet, HttpPost])
get "/users": get "/users":
checkAuth(true) checkAuth(true)
resp(Http200, $(%ctx.db.getAllUsers())) jsonResp($(%ctx.db.getAllUsers()))
post "/users": post "/users":
checkAuth(true) checkAuth(true)
@ -290,18 +348,20 @@ proc start*(ctx: PMApiContext): void =
salt: pwdAndSalt.salt, salt: pwdAndSalt.salt,
isAdmin: false) isAdmin: false)
resp($(%ctx.db.createUser(newUser)), JSON) jsonResp($(%ctx.db.createUser(newUser)))
except JsonParsingError: jsonResp(Http400, getCurrentExceptionMsg()) except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: jsonResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except: except:
error "Could not create new user:\n\t" & getCurrentExceptionMsg() error "Could not create new user:\n\t" & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
options "/users/@userId": optionsResp(@[HttpGet, HttpDelete])
get "/users/@userId": get "/users/@userId":
checkAuth(true) checkAuth(true)
resp(Http200, $(%ctx.db.getUser(parseUUID(@"userId")))) jsonResp($(%ctx.db.getUser(parseUUID(@"userId"))))
delete "/users/@userId": delete "/users/@userId":
checkAuth(true) checkAuth(true)
@ -310,18 +370,20 @@ proc start*(ctx: PMApiContext): void =
try: try:
let userId = parseUUID(@"userId") let userId = parseUUID(@"userId")
user = ctx.db.getUser(userId) user = ctx.db.getUser(userId)
except: jsonResp(Http404) except: statusResp(Http404)
try: try:
if not ctx.db.deleteUser(user): raiseEx "unable to delete user" if not ctx.db.deleteUser(user): raiseEx "unable to delete user"
jsonResp(Http200, "user " & user.email & " deleted") statusResp(Http200, "user " & user.email & " deleted")
except: jsonResp(Http500, getCurrentExceptionMsg()) except: statusResp(Http500, getCurrentExceptionMsg())
options "/api-tokens": optionsResp(@[HttpGet, HttpPost])
get "/api-tokens": get "/api-tokens":
checkAuth() checkAuth()
resp(Http200, $(%ctx.db.findApiTokensByUserId($session.user.id))) jsonResp($(%ctx.db.findApiTokensByUserId($session.user.id)))
post "/api-tokens": post "/api-tokens":
checkAuth() checkAuth()
@ -343,40 +405,46 @@ proc start*(ctx: PMApiContext): void =
let respToken = %newToken let respToken = %newToken
respToken["value"] = %tokenValue respToken["value"] = %tokenValue
resp($respToken, JSON) jsonResp($respToken)
except JsonParsingError: jsonResp(Http400, getCurrentExceptionMsg()) except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: jsonResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except AuthError: jsonResp(Http401, getCurrentExceptionMsg()) except AuthError: statusResp(Http401, getCurrentExceptionMsg())
except: except:
debug getCurrentExceptionMsg() debug getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
options "/api-tokens/@tokenId": optionsResp(@[HttpGet, HttpDelete])
get "/api-tokens/@tokenId": get "/api-tokens/@tokenId":
checkAuth() checkAuth()
try: try:
resp(Http200, $(%ctx.db.getApiToken(parseUUID(@"tokenId")))) jsonResp($(%ctx.db.getApiToken(parseUUID(@"tokenId"))))
except NotFoundError: jsonResp(Http404, getCurrentExceptionMsg()) except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: jsonResp(Http500) except: statusResp(Http500)
delete "/api-tokens/@tokenId": delete "/api-tokens/@tokenId":
checkAuth() checkAuth()
try: try:
let token = ctx.db.getApiToken(parseUUID(@"tokenId")) let token = ctx.db.getApiToken(parseUUID(@"tokenId"))
if ctx.db.deleteApiToken(token): jsonResp(Http200) if ctx.db.deleteApiToken(token): statusResp(Http200)
else: jsonResp(Http500) else: statusResp(Http500)
except NotFoundError: jsonResp(Http404, getCurrentExceptionMsg()) except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: jsonResp(Http500) except: statusResp(Http500)
# Measure
options "/measures": optionsResp(@[HttpGet, HttpPost])
get "/measures": get "/measures":
checkAuth() checkAuth()
try: resp($(%ctx.db.findMeasuresByUserId($session.user.id)), JSON) try: jsonResp($(%ctx.db.findMeasuresByUserId($session.user.id)))
except: except:
error "unable to retrieve measures for user:\n\t" & getCurrentExceptionMsg() error "unable to retrieve measures for user:\n\t" & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
post "/measures": post "/measures":
checkAuth() checkAuth()
@ -406,47 +474,84 @@ proc start*(ctx: PMApiContext): void =
description: jsonBody.getIfExists("description").getStr(""), description: jsonBody.getIfExists("description").getStr(""),
config: config) config: config)
resp($(%ctx.db.createMeasure(newMeasure)), JSON) jsonResp($(%ctx.db.createMeasure(newMeasure)))
except JsonParsingError: jsonResp(Http400, getCurrentExceptionMsg()) except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: jsonResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except: except:
error "unable to create new measure:\n\t" & getCurrentExceptionMsg() error "unable to create new measure:\n\t" & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
options "/measures/@slug": optionsResp(@[HttpGet, HttpPost, HttpDelete])
get "/measures/@slug": get "/measures/@slug":
checkAuth() checkAuth()
try: resp($(%ctx.getMeasureForSlug(session.user.id, @"slug")), JSON) try: jsonResp($(%ctx.getMeasureForSlug(session.user.id, @"slug")))
except NotFoundError: jsonResp(Http404, getCurrentExceptionMsg()) except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: except:
error "unable to look up a measure by id:\n\t" & getCurrentExceptionMsg() error "unable to look up a measure by id:\n\t" & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
post "/measures/@slug":
checkAuth()
try:
let jsonBody = parseJson(request.body)
var existingMeasure = ctx.getMeasureForSlug(session.user.id, @"slug")
if not (jsonBody.hasKey("slug") or jsonBody.hasKey("name")):
raiseEx BadRequestError, "body must contain either the 'slug' field (short name), or the 'name' field, or both"
existingMeasure.slug =
if jsonBody.hasKey("slug"): jsonBody["slug"].getStr.nameToSlug
else: jsonBody["name"].getStr.nameToSlug
existingMeasure.name =
if jsonBody.hasKey("name"): jsonBody["name"].getStr
else: jsonBody["slug"].getStr.capitalize
if jsonBody.hasKey("config"): existingMeasure.config = jsonBody["config"]
if jsonBody.hasKey("description"): existingMeasure.description = jsonBody["description"].getStr
jsonResp($(%ctx.db.updateMeasure(existingMeasure)))
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except:
error "unable to update measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
delete "/measures/@slug": delete "/measures/@slug":
checkAuth() checkAuth()
try: try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug") let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
if ctx.db.deleteMeasure(measure): jsonResp(Http200) if ctx.db.deleteMeasure(measure): statusResp(Http200)
else: raiseEx "" else: raiseEx ""
except NotFoundError: jsonResp(Http404, getCurrentExceptionMsg()) except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: except:
error "unable to delete a measure:\n\t" & getCurrentExceptionMsg() error "unable to delete a measure:\n\t" & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
get "/measure/@slug": # Measurements
options "/measurements/@slug": optionsResp(@[HttpGet, HttpPost])
get "/measurements/@slug":
checkAuth() checkAuth()
try: try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug") let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
resp($(%ctx.db.findMeasurementsByMeasureId($measure.id)), JSON) jsonResp($(%ctx.db.findMeasurementsByMeasureId($measure.id)))
except NotFoundError: jsonResp(Http404, getCurrentExceptionMsg()) except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: except:
error "unable to list measurements:\n\t" & getCurrentExceptionMsg() error "unable to list measurements:\n\t" & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
post "/measure/@slug": post "/measurements/@slug":
checkAuth() checkAuth()
try: try:
@ -455,7 +560,7 @@ proc start*(ctx: PMApiContext): void =
let newMeasurement = Measurement( let newMeasurement = Measurement(
measureId: measure.id, measureId: measure.id,
value: jsonBody.getOrFail("value").getInt, value: jsonBody.getOrFail("value").getFloat,
timestamp: timestamp:
if jsonBody.hasKey("timestamp"): jsonBody["timestamp"].getStr.parseIso8601.utc if jsonBody.hasKey("timestamp"): jsonBody["timestamp"].getStr.parseIso8601.utc
else: getTime().utc, else: getTime().utc,
@ -463,66 +568,70 @@ proc start*(ctx: PMApiContext): void =
if jsonBody.hasKey("extData"): jsonBody["extData"] if jsonBody.hasKey("extData"): jsonBody["extData"]
else: newJObject()) else: newJObject())
resp($(%ctx.db.createMeasurement(newMeasurement)), JSON) jsonResp($(%ctx.db.createMeasurement(newMeasurement)))
except JsonParsingError: jsonResp(Http400, getCurrentExceptionMsg()) except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: jsonResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except NotFoundError: jsonResp(Http404, getCurrentExceptionMsg()) except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: except:
error "unable to add measurement:\n\t" & getCurrentExceptionMsg() error "unable to add measurement:\n\t" & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
get "/measure/@slug/@id": options "/measurements/@slug/@id": optionsResp(@[HttpGet, HttpPut, HttpDelete])
get "/measurements/@slug/@id":
checkAuth() checkAuth()
try: try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug") let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
resp($(%ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id"))), JSON) jsonResp($(%ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id"))))
except ValueError: jsonResp(Http400, getCurrentExceptionMsg()) except ValueError: statusResp(Http400, getCurrentExceptionMsg())
except JsonParsingError: jsonResp(Http400, getCurrentExceptionMsg()) except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: jsonResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except NotFoundError: jsonResp(Http404, getCurrentExceptionMsg()) except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: except:
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg() error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
put "/measure/@slug/@id": put "/measurements/@slug/@id":
checkAuth() checkAuth()
try: try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug") let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
var measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id")) var measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id"))
let jsonBody = parseJson(request.body) let jsonBody = parseJson(request.body)
if jsonBody.hasKey("value"): measurement.value = jsonBody["value"].getInt if jsonBody.hasKey("value"): measurement.value = jsonBody["value"].getFloat
if jsonBody.hasKey("timestamp"): measurement.timestamp = jsonBody["timestamp"].getStr.parseIso8601 if jsonBody.hasKey("timestamp"): measurement.timestamp = jsonBody["timestamp"].getStr.parseIso8601
if jsonBody.hasKey("extData"): measurement.extData = jsonBody["extData"] if jsonBody.hasKey("extData"): measurement.extData = jsonBody["extData"]
resp($(%ctx.db.updateMeasurement(measurement)), JSON) jsonResp($(%ctx.db.updateMeasurement(measurement)))
except ValueError: jsonResp(Http400, getCurrentExceptionMsg()) except ValueError: statusResp(Http400, getCurrentExceptionMsg())
except JsonParsingError: jsonResp(Http400, getCurrentExceptionMsg()) except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: jsonResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except NotFoundError: jsonResp(Http404, getCurrentExceptionMsg()) except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: except:
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg() error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
delete "/measure/@slug/@id": delete "/measurements/@slug/@id":
checkAuth() checkAuth()
try: try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug") let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
let measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id")) let measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id"))
if ctx.db.deleteMeasurement(measurement): jsonResp(Http200) if ctx.db.deleteMeasurement(measurement): statusResp(Http200)
else: raiseEx "" else: raiseEx ""
except ValueError: jsonResp(Http400, getCurrentExceptionMsg()) except ValueError: statusResp(Http400, getCurrentExceptionMsg())
except JsonParsingError: jsonResp(Http400, getCurrentExceptionMsg()) except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: jsonResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except NotFoundError: jsonResp(Http404, getCurrentExceptionMsg()) except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: except:
error "unable to delete measurement:\n\t" & getCurrentExceptionMsg() error "unable to delete measurement:\n\t" & getCurrentExceptionMsg()
jsonResp(Http500) statusResp(Http500)
options "/log": optionsResp(@[HttpPost])
post "/log": post "/log":
checkAuth() checkAuth()
@ -537,9 +646,11 @@ proc start*(ctx: PMApiContext): void =
stacktrace: jsonBody.getIfExists("stacktrace").getStr(""), stacktrace: jsonBody.getIfExists("stacktrace").getStr(""),
timestamp: jsonBody.getOrFail("timestamp").getStr.parseIso8601 timestamp: jsonBody.getOrFail("timestamp").getStr.parseIso8601
) )
resp(Http200, $(%ctx.db.createClientLogEntry(logEntry)), JSON) jsonResp($(%ctx.db.createClientLogEntry(logEntry)))
except BadRequestError: jsonResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except: jsonResp(Http500, getCurrentExceptionMsg()) except: statusResp(Http500, getCurrentExceptionMsg())
options "/log/batch": optionsResp(@[HttpPost])
post "/log/batch": post "/log/batch":
checkAuth() checkAuth()
@ -555,15 +666,15 @@ proc start*(ctx: PMApiContext): void =
stacktrace: it.getIfExists("stacktrace").getStr(""), stacktrace: it.getIfExists("stacktrace").getStr(""),
timestamp: it.getOrFail("timestamp").getStr.parseIso8601 timestamp: it.getOrFail("timestamp").getStr.parseIso8601
)) ))
resp(Http200, $(%respMsgs), JSON) jsonResp($(%respMsgs))
except BadRequestError: jsonResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except: jsonResp(Http500, getCurrentExceptionMsg()) except: statusResp(Http500, getCurrentExceptionMsg())
post "/service/debug/stop": post "/service/debug/stop":
if not ctx.cfg.debug: jsonResp(Http404) if not ctx.cfg.debug: statusResp(Http404)
else: else:
let shutdownFut = sleepAsync(100) let shutdownFut = sleepAsync(100)
shutdownFut.callback = proc(): void = complete(stopFuture) shutdownFut.callback = proc(): void = complete(stopFuture)
resp($(%"shutting down"), JSON) jsonResp($(%"shutting down"))
waitFor(stopFuture) waitFor(stopFuture)

View File

@ -9,6 +9,7 @@ type
debug*: bool debug*: bool
port*: int port*: int
pwdCost*: int8 pwdCost*: int8
knownOrigins*: seq[string]
PMApiContext* = object PMApiContext* = object
cfg*: PMApiConfig cfg*: PMApiConfig
@ -23,7 +24,8 @@ proc `%`*(cfg: PMApiConfig): JsonNode =
"dbConnString": cfg.dbConnString, "dbConnString": cfg.dbConnString,
"debug": cfg.debug, "debug": cfg.debug,
"port": cfg.port, "port": cfg.port,
"pwdCost": cfg.pwdCost } "pwdCost": cfg.pwdCost,
"knownOrigins": cfg.knownOrigins }
template raiseEx*(errorType: type, reason: string): void = template raiseEx*(errorType: type, reason: string): void =
raise newException(errorType, reason) raise newException(errorType, reason)

View File

@ -1,10 +1,8 @@
import db_postgres, macros, options, postgres, sequtils, strutils, import db_postgres, fiber_orm, sequtils, uuids
times, timeutils, unicode, uuids
import ./models import ./models
import ./db_common
export db_common.NotFoundError export fiber_orm.NotFoundError
type type
PMApiDb* = ref object PMApiDb* = ref object
@ -14,18 +12,24 @@ type
proc connect*(connString: string): PMApiDb = proc connect*(connString: string): PMApiDb =
result = PMApiDb(conn: open("", "", "", connString)) result = PMApiDb(conn: open("", "", "", connString))
generateProcsForModels([User, ApiToken, Measure, Measurement, ClientLogEntry]) generateProcsForModels(PMApiDb, [
User,
ApiToken,
Measure,
Measurement,
ClientLogEntry
])
generateLookup(User, @["email"]) generateLookup(PMApiDb, User, @["email"])
generateLookup(ApiToken, @["userId"]) generateLookup(PMApiDb, ApiToken, @["userId"])
generateLookup(ApiToken, @["hashedToken"]) generateLookup(PMApiDb, ApiToken, @["hashedToken"])
generateLookup(Measure, @["userId"]) generateLookup(PMApiDb, Measure, @["userId"])
generateLookup(Measure, @["userId", "id"]) generateLookup(PMApiDb, Measure, @["userId", "id"])
generateLookup(Measure, @["userId", "slug"]) generateLookup(PMApiDb, Measure, @["userId", "slug"])
generateLookup(Measurement, @["measureId"]) generateLookup(PMApiDb, Measurement, @["measureId"])
generateLookup(Measurement, @["measureId", "id"]) generateLookup(PMApiDb, Measurement, @["measureId", "id"])
generateLookup(ClientLogEntry, @["userId"]) generateLookup(PMApiDb, ClientLogEntry, @["userId"])

View File

@ -1,150 +0,0 @@
import db_postgres, macros, options, sequtils, strutils, uuids
from unicode import capitalize
import ./db_util
type NotFoundError* = object of CatchableError
proc newMutateClauses(): MutateClauses =
return MutateClauses(
columns: @[],
placeholders: @[],
values: @[])
proc createRecord*[T](db: DbConn, rec: T): T =
var mc = newMutateClauses()
populateMutateClauses(rec, true, mc)
# Confusingly, getRow allows inserts and updates. We use it to get back the ID
# we want from the row.
let newRow = db.getRow(sql(
"INSERT INTO " & tableName(rec) &
" (" & mc.columns.join(",") & ") " &
" VALUES (" & mc.placeholders.join(",") & ") " &
" RETURNING *"), mc.values)
result = rowToModel(T, newRow)
proc updateRecord*[T](db: DbConn, rec: T): bool =
var mc = newMutateClauses()
populateMutateClauses(rec, false, mc)
let setClause = zip(mc.columns, mc.placeholders).mapIt(it.a & " = " & it.b).join(",")
let numRowsUpdated = db.execAffectedRows(sql(
"UPDATE " & tableName(rec) &
" SET " & setClause &
" WHERE id = ? "), mc.values.concat(@[$rec.id]))
return numRowsUpdated > 0;
template deleteRecord*(db: DbConn, modelType: type, id: typed): untyped =
db.tryExec(sql("DELETE FROM " & tableName(modelType) & " WHERE id = ?"), $id)
proc deleteRecord*[T](db: DbConn, rec: T): bool =
return db.tryExec(sql("DELETE FROM " & tableName(rec) & " WHERE id = ?"), $rec.id)
template getRecord*(db: DbConn, modelType: type, id: typed): untyped =
let row = db.getRow(sql(
"SELECT " & columnNamesForModel(modelType).join(",") &
" FROM " & tableName(modelType) &
" WHERE id = ?"), @[$id])
if row.allIt(it.len == 0):
raise newException(NotFoundError, "no record for id " & $id)
rowToModel(modelType, row)
template findRecordsWhere*(db: DbConn, modelType: type, whereClause: string, values: varargs[string, dbFormat]): untyped =
db.getAllRows(sql(
"SELECT " & columnNamesForModel(modelType).join(",") &
" FROM " & tableName(modelType) &
" WHERE " & whereClause), values)
.mapIt(rowToModel(modelType, it))
template getAllRecords*(db: DbConn, modelType: type): untyped =
db.getAllRows(sql(
"SELECT " & columnNamesForModel(modelType).join(",") &
" FROM " & tableName(modelType)))
.mapIt(rowToModel(modelType, it))
template findRecordsBy*(db: DbConn, modelType: type, lookups: seq[tuple[field: string, value: string]]): untyped =
db.getAllRows(sql(
"SELECT " & columnNamesForModel(modelType).join(",") &
" FROM " & tableName(modelType) &
" WHERE " & lookups.mapIt(it.field & " = ?").join(" AND ")),
lookups.mapIt(it.value))
.mapIt(rowToModel(modelType, it))
macro generateProcsForModels*(modelTypes: openarray[type]): untyped =
result = newStmtList()
for t in modelTypes:
let modelName = $(t.getType[1])
let getName = ident("get" & modelName)
let getAllName = ident("getAll" & modelName & "s")
let findWhereName = ident("find" & modelName & "sWhere")
let createName = ident("create" & modelName)
let updateName = ident("update" & modelName)
let deleteName = ident("delete" & modelName)
let idType = typeOfColumn(t, "id")
result.add quote do:
proc `getName`*(db: PMApiDb, id: `idType`): `t` = getRecord(db.conn, `t`, id)
proc `getAllName`*(db: PMApiDb): seq[`t`] = getAllRecords(db.conn, `t`)
proc `findWhereName`*(db: PMApiDb, whereClause: string, values: varargs[string, dbFormat]): seq[`t`] =
return findRecordsWhere(db.conn, `t`, whereClause, values)
proc `createName`*(db: PMApiDb, rec: `t`): `t` = createRecord(db.conn, rec)
proc `updateName`*(db: PMApiDb, rec: `t`): bool = updateRecord(db.conn, rec)
proc `deleteName`*(db: PMApiDb, rec: `t`): bool = deleteRecord(db.conn, rec)
proc `deleteName`*(db: PMApiDb, id: `idType`): bool = deleteRecord(db.conn, `t`, id)
macro generateLookup*(modelType: type, fields: seq[string]): untyped =
let fieldNames = fields[1].mapIt($it)
let procName = ident("find" & $modelType.getType[1] & "sBy" & fieldNames.mapIt(it.capitalize).join("And"))
# Create proc skeleton
result = quote do:
proc `procName`*(db: PMApiDb): seq[`modelType`] =
return findRecordsBy(db.conn, `modelType`)
var callParams = quote do: @[]
# Add dynamic parameters for the proc definition and inner proc call
for n in fieldNames:
let paramTuple = newNimNode(nnkPar)
paramTuple.add(newColonExpr(ident("field"), newLit(identNameToDb(n))))
paramTuple.add(newColonExpr(ident("value"), ident(n)))
result[3].add(newIdentDefs(ident(n), ident("string")))
callParams[1].add(paramTuple)
result[6][0][0].add(callParams)
macro generateProcsForFieldLookups*(modelsAndFields: openarray[tuple[t: type, fields: seq[string]]]): untyped =
result = newStmtList()
for i in modelsAndFields:
var modelType = i[1][0]
let fieldNames = i[1][1][1].mapIt($it)
let procName = ident("find" & $modelType & "sBy" & fieldNames.mapIt(it.capitalize).join("And"))
# Create proc skeleton
let procDefAST = quote do:
proc `procName`*(db: PMApiDb): seq[`modelType`] =
return findRecordsBy(db.conn, `modelType`)
var callParams = quote do: @[]
# Add dynamic parameters for the proc definition and inner proc call
for n in fieldNames:
let paramTuple = newNimNode(nnkPar)
paramTuple.add(newColonExpr(ident("field"), newLit(n)))
paramTuple.add(newColonExpr(ident("value"), ident(n)))
procDefAST[3].add(newIdentDefs(ident(n), ident("string")))
callParams[1].add(paramTuple)
procDefAST[6][0][0].add(callParams)
result.add procDefAST

View File

@ -1,285 +0,0 @@
import json, macros, options, sequtils, strutils, times, timeutils, unicode,
uuids
const UNDERSCORE_RUNE = "_".toRunes[0]
const PG_TIMESTAMP_FORMATS = [
"yyyy-MM-dd HH:mm:sszz",
"yyyy-MM-dd HH:mm:ss'.'fffzz"
]
type
MutateClauses* = object
columns*: seq[string]
placeholders*: seq[string]
values*: seq[string]
# TODO: more complete implementation
# see https://github.com/blakeembrey/pluralize
proc pluralize(name: string): string =
if name[^2..^1] == "ey": return name[0..^3] & "ies"
if name[^1] == 'y': return name[0..^2] & "ies"
return name & "s"
macro modelName*(model: object): string =
return $model.getTypeInst
macro modelName*(modelType: type): string =
return $modelType.getType[1]
proc identNameToDb*(name: string): string =
let nameInRunes = name.toRunes
var prev: Rune
var resultRunes = newSeq[Rune]()
for cur in nameInRunes:
if resultRunes.len == 0:
resultRunes.add(toLower(cur))
elif isLower(prev) and isUpper(cur):
resultRunes.add(UNDERSCORE_RUNE)
resultRunes.add(toLower(cur))
else: resultRunes.add(toLower(cur))
prev = cur
return $resultRunes
proc dbNameToIdent*(name: string): string =
let parts = name.split("_")
return @[parts[0]].concat(parts[1..^1].mapIt(capitalize(it))).join("")
proc tableName*(modelType: type): string =
return pluralize(modelName(modelType).identNameToDb)
proc tableName*[T](rec: T): string =
return pluralize(modelName(rec).identNameToDb)
proc dbFormat*(s: string): string = return s
proc dbFormat*(dt: DateTime): string = return dt.formatIso8601
proc dbFormat*[T](list: seq[T]): string =
return "{" & list.mapIt(dbFormat(it)).join(",") & "}"
proc dbFormat*[T](item: T): string = return $item
type DbArrayParseState = enum
expectStart, inQuote, inVal, expectEnd
proc parsePGDatetime*(val: string): DateTime =
var errStr = ""
for df in PG_TIMESTAMP_FORMATS:
try: return val.parse(df)
except: errStr &= "\n" & getCurrentExceptionMsg()
raise newException(ValueError, "Cannot parse PG date. Tried:" & errStr)
proc parseDbArray*(val: string): seq[string] =
result = newSeq[string]()
var parseState = DbArrayParseState.expectStart
var curStr = ""
var idx = 1
var sawEscape = false
while idx < val.len - 1:
var curChar = val[idx]
idx += 1
case parseState:
of expectStart:
if curChar == ' ': continue
elif curChar == '"':
parseState = inQuote
continue
else:
parseState = inVal
of expectEnd:
if curChar == ' ': continue
elif curChar == ',':
result.add(curStr)
curStr = ""
parseState = expectStart
continue
of inQuote:
if curChar == '"' and not sawEscape:
parseState = expectEnd
continue
of inVal:
if curChar == '"' and not sawEscape:
raise newException(ValueError, "Invalid DB array value (cannot have '\"' in the middle of an unquoted string).")
elif curChar == ',':
result.add(curStr)
curStr = ""
parseState = expectStart
continue
# if we saw an escaped \", add just the ", otherwise add both
if sawEscape:
if curChar != '"': curStr.add('\\')
curStr.add(curChar)
sawEscape = false
elif curChar == '\\':
sawEscape = true
else: curStr.add(curChar)
if not (parseState == inQuote) and curStr.len > 0:
result.add(curStr)
proc createParseStmt*(t, value: NimNode): NimNode =
#echo "Creating parse statment for ", t.treeRepr
if t.typeKind == ntyObject:
if t.getType == UUID.getType:
result = quote do: parseUUID(`value`)
elif t.getType == DateTime.getType:
result = quote do: parsePGDatetime(`value`)
elif t.getTypeInst == Option.getType:
let innerType = t.getTypeImpl[2][0][0][1]
let parseStmt = createParseStmt(innerType, value)
result = quote do:
if `value`.len == 0: none[`innerType`]()
else: some(`parseStmt`)
else: error "Unknown value object type: " & $t.getTypeInst
elif t.typeKind == ntyRef:
if $t.getTypeInst == "JsonNode":
result = quote do: parseJson(`value`)
else:
error "Unknown ref type: " & $t.getTypeInst
elif t.typeKind == ntySequence:
let innerType = t[1]
let parseStmts = createParseStmt(innerType, ident("it"))
result = quote do: parseDbArray(`value`).mapIt(`parseStmts`)
elif t.typeKind == ntyString:
result = quote do: `value`
elif t.typeKind == ntyInt:
result = quote do: parseInt(`value`)
elif t.typeKind == ntyBool:
result = quote do: "true".startsWith(`value`.toLower)
else:
error "Unknown value type: " & $t.typeKind
template walkFieldDefs*(t: NimNode, body: untyped) =
let tTypeImpl = t.getTypeImpl
var nodeToItr: NimNode
if tTypeImpl.typeKind == ntyObject: nodeToItr = tTypeImpl[2]
elif tTypeImpl.typeKind == ntyTypeDesc: nodeToItr = tTypeImpl.getType[1].getType[2]
else: error $t & " is not an object or type desc (it's a " & $tTypeImpl.typeKind & ")."
for fieldDef {.inject.} in nodeToItr.children:
# ignore AST nodes that are not field definitions
if fieldDef.kind == nnkIdentDefs:
let fieldIdent {.inject.} = fieldDef[0]
let fieldType {.inject.} = fieldDef[1]
body
elif fieldDef.kind == nnkSym:
let fieldIdent {.inject.} = fieldDef
let fieldType {.inject.} = fieldDef.getType
body
macro columnNamesForModel*(modelType: typed): seq[string] =
var columnNames = newSeq[string]()
modelType.walkFieldDefs:
columnNames.add(identNameToDb($fieldIdent))
result = newLit(columnNames)
macro rowToModel*(modelType: typed, row: seq[string]): untyped =
# Create the object constructor AST node
result = newNimNode(nnkObjConstr).add(modelType)
# Create new colon expressions for each of the property initializations
var idx = 0
modelType.walkFieldDefs:
let itemLookup = quote do: `row`[`idx`]
result.add(newColonExpr(
fieldIdent,
createParseStmt(fieldType, itemLookup)))
idx += 1
macro listFields*(t: typed): untyped =
var fields: seq[tuple[n: string, t: string]] = @[]
t.walkFieldDefs:
if fieldDef.kind == nnkSym: fields.add((n: $fieldIdent, t: fieldType.repr))
else: fields.add((n: $fieldIdent, t: $fieldType))
result = newLit(fields)
proc typeOfColumn*(modelType: NimNode, colName: string): NimNode =
modelType.walkFieldDefs:
if $fieldIdent != colName: continue
if fieldType.typeKind == ntyObject:
if fieldType.getType == UUID.getType: return ident("UUID")
elif fieldType.getType == DateTime.getType: return ident("DateTime")
elif fieldType.getType == Option.getType: return ident("Option")
else: error "Unknown column type: " & $fieldType.getTypeInst
else: return fieldType
raise newException(Exception,
"model of type '" & $modelType & "' has no column named '" & colName & "'")
proc isZero(val: int): bool = return val == 0
macro populateMutateClauses*(t: typed, newRecord: bool, mc: var MutateClauses): untyped =
result = newStmtList()
# iterate over all the object's fields
t.walkFieldDefs:
# grab the field, it's string name, and it's type
let fieldName = $fieldIdent
# we do not update the ID, but we do check: if we're creating a new
# record, we should not have an existing ID
if fieldName == "id":
result.add quote do:
if `newRecord` and not `t`.id.isZero:
raise newException(
AssertionError,
"Trying to create a new record, but the record already has an ID (" & $(`t`.id) & ").")
# if we're looking at an optional field, add logic to check for presence
elif fieldType.kind == nnkBracketExpr and
fieldType.len > 0 and
fieldType[0] == Option.getType:
result.add quote do:
`mc`.columns.add(identNameToDb(`fieldName`))
if `t`.`fieldIdent`.isSome:
`mc`.placeholders.add("?")
`mc`.values.add(dbFormat(`t`.`fieldIdent`.get))
else:
`mc`.placeholders.add("NULL")
# otherwise assume we can convert and go ahead.
else:
result.add quote do:
`mc`.columns.add(identNameToDb(`fieldName`))
`mc`.placeholders.add("?")
`mc`.values.add(dbFormat(`t`.`fieldIdent`))

View File

@ -28,7 +28,7 @@ type
Measurement* = object Measurement* = object
id*: UUID id*: UUID
measureId*: UUID measureId*: UUID
value*: int value*: float
timestamp*: DateTime timestamp*: DateTime
extData*: JsonNode extData*: JsonNode

View File

@ -1 +1 @@
const PM_API_VERSION* = "0.1.0" const PM_API_VERSION* = "0.11.0"

View File

@ -0,0 +1,2 @@
-- DOWN script for measure-value-is-numeric (20200216230431)
alter table "measurements" alter column "value" type integer;

View File

@ -0,0 +1,2 @@
-- UP script for measure-value-is-numeric (20200216230431)
alter table "measurements" alter column "value" type numeric;

View File

@ -7,3 +7,6 @@ User=pmapi
WorkingDirectory=/home/pmapi WorkingDirectory=/home/pmapi
ExecStart=/home/pmapi/personal_measure_api ExecStart=/home/pmapi/personal_measure_api
Restart=on-failure Restart=on-failure
[Install]
WantedBy=multi-user.target

View File

@ -1,22 +1,37 @@
#!/bin/bash #!/bin/bash
host="${PM_API_HOST:-localhost:8081}" api_base_url="${PM_API_BASE_URL:-http://localhost:8100/v0}"
if [ $# -eq 1 ]; then if [ $# -eq 1 ]; then
url="$1" url="$1"
method="GET" method="GET"
data="" data=""
elif [ $# -eq 2 ]; then elif [ $# -eq 2 ]; then
method="$1" if [ $1 == "auth-token" ]; then
url="$2" curl -s -X POST \
data="" -H "Origin: https://curl.localhost" \
"${api_base_url}/auth-token" \
-d "$2" \
| xargs printf "Bearer %s" \
> credential
exit 0
else
method="$1"
url="$2"
data=""
fi
else else
method="$1" method="$1"
url="$2" url="$2"
data="$3" data="$3"
fi fi
if [[ ! $url = /* ]]; then url="/$url"; fi
curl -s -X "$method" \ curl -s -X "$method" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Authorization: $(cat credential)"\ -H "Authorization: $(cat credential)" \
"http://${host}/api/$url" \ -H "Origin: https://curl.localhost" \
-d "$data" "${api_base_url}$url" \
-d "$data" \
| jq .

View File

@ -0,0 +1 @@
### Add the ability to delete measures.

View File

@ -0,0 +1,5 @@
### Add support for text entries.
Thinking is to allow recording an arbitrary text value alonside a timestamp.
Presentation would be something like a list of the values. Probably no graph
(maybe something like a histogram for some use cases?).

9
doc/issues/new-issue.sh Executable file
View File

@ -0,0 +1,9 @@
id=$(cat next-issue-number.txt)
printf "%03d" "$(expr $id + 1)" > next-issue-number.txt
printf "Title/Summary?\n> "
read -r summary
slugSummary=$(echo "$summary" | tr "[:upper:]" "[:lower:]" | tr ' ' - )
slugSummary="${slugSummary//.}"
echo "### $summary" > "$id-$slugSummary.md"

View File

@ -0,0 +1 @@
010

View File

@ -0,0 +1,8 @@
### Provide options for graphing measures.
As a user I would like to be able to configure the graph used for a measure.
Needed:
- General pattern for graph configuration.
- Support for different graph types (line, bar, pie, area, others?).
- Support for pre-processing of data (graph rolling average, etc).

View File

@ -0,0 +1,10 @@
### Add a timestamp meaure type (no value).
As a user I would like to be able to measure when things happen (fall asleep)
with a simple timestamp.
#### Implementation notes:
This may not require a new storage type (just use the existing SimpleMeasure)
but UI (input, graphs, etc.) would just ignore the value and only consider the
timestamp.

View File

@ -0,0 +1 @@
### Add the ability to delete measurements.

View File

@ -0,0 +1 @@
### Add the ability to edit measurements.

View File

@ -0,0 +1 @@
### Add the ability to edit measures.

View File

@ -0,0 +1 @@
### Support rolling averages in graph displays.

View File

@ -0,0 +1,3 @@
### Toggle Measure Visibility
Allow the user to choose whether a measure should be visible or hidden by default.

View File

@ -0,0 +1,3 @@
### Grouped Measures
Create a measure type that is just a grouping of several other measures. For example, it would be nice to be able to group all workout-related measures into one group. The graph could show an overlay of all the different measures on one graph.

65
operations/doc.md Normal file
View File

@ -0,0 +1,65 @@
### Web App Hosting (static HTML)
Web app is deployed to an S3 bucket. Each top level directory in this bucket
represents a different deployed environment. For example:
s3://pm.jdb-labs.com
├── prod
│   ├── logs
│   │   └── cloudfront
│ └── webroot
   ├── css
   ├── img
   ├── js
   ├── index.html
│ └── ...
└── dev
   ├── logs
└── webroot
### API Hosting
API is served from razgriz:
* dev: https://pmapi-dev.jdb-labs.com (:80 -> :8281)
* prod: https://pmapi.jdb-labs.com (:80 -> :8280)
#### Server Setup Notes
The home folder of the `pmapi` user, like the S3 bucket, contains one folder
for each environment:
/home/pmapi
├── dev
│ ├── personal_measure_api
│ └── personal_measuer_api.config.json
└── prod
├── personal_measure_api
└── personal_measuer_api.config.json
As part of the automated deployment process, pmapi.jdb-labs.com has one systemd
service definitions for each environment. The sudoers file allows the `pmapi`
user to manage these without a password.
# Allow pmapi to manage the personal_measure_api service
pmapi ALL=NOPASSWD: /bin/systemctl stop personal_measure_api.prod.service
pmapi ALL=NOPASSWD: /bin/systemctl start personal_measure_api.prod.service
pmapi ALL=NOPASSWD: /bin/systemctl stop personal_measure_api.dev.service
pmapi ALL=NOPASSWD: /bin/systemctl start personal_measure_api.dev.service
### Database
razgriz-db.jdb-labs.com RDS instance maintains databases for each environment:
* dev: `personal_measure_dev`
* prod: `personal_measure`
### Routing
CloudFront manages the routing of all of the external facing URLs.
https://pm.jdb-labs.com (CloudFront)
└── s3://pm.jdb-labs.com/prod/webroot (static HTML)
https://pm-dev.jdb-labs.com (CloudFront)
└── s3://pm.jdb-labs.com/dev/webroot (static HTML)

View File

@ -0,0 +1,28 @@
#!/bin/bash
echo "Looking up CloudFront distribution ID for Personal Measure ${TARGET_ENV} environment."
cloudfront_distribution_id=$(\
aws cloudfront list-distributions \
--query "DistributionList.Items[?starts_with(Comment, 'Personal Measure ${TARGET_ENV}')].Id | [0]" \
| sed -e 's/^"//' -e 's/"$//'
)
if [[ -z "${cloudfront_distribution_id}" ]]; then
>&2 echo "Unable to find CloudFront distribution for domain ${TARGET_ENV}."
exit 3
fi
echo "Found distribution ID ${cloudfront_distribution_id}."
echo "Invalidating the CloudFront cache for ${TARGET_ENV}."
invalidation_id=$(aws cloudfront create-invalidation \
--query 'Invalidation.Id' \
--distribution-id "${cloudfront_distribution_id}" \
--paths '/index.html')
if [[ $? -ne 0 || -z "${invalidation_id}" ]]; then
>&2 echo "Unable to create the CloudFront invalidation."
else
echo "Successfully created invalidation ${invalidation_id}."
fi
echo "Done."

View File

@ -0,0 +1,11 @@
### Variables
variable "aws_region" {
description = "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html"
default = "us-west-2" # Oregon
}
variable "app_root_url" {
description = "Name of the S3 bucket to store deployed artifacts, logs, etc."
default = "pm.jdb-software.com"
}

View File

@ -0,0 +1,98 @@
data "aws_iam_policy_document" "bucket_access_policy" {
statement {
actions = [ "s3:GetObject" ]
effect = "Allow"
resources = [ "${var.artifact_bucket.arn}/${var.environment}/webroot/*" ]
principals {
type = "AWS"
identifiers = [ aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn ]
}
}
statement {
actions = [ "s3:ListBucket" ]
effect = "Allow"
resources = [ var.artifact_bucket.arn ]
principals {
type = "AWS"
identifiers = [ aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn ]
}
}
}
output "oai_access_policy" {
value = data.aws_iam_policy_document.bucket_access_policy
}
resource "aws_cloudfront_origin_access_identity" "origin_access_identity" {
comment = "OAI for Personal Measure {$var.environment} environment."
}
resource "aws_cloudfront_distribution" "s3_distribution" {
origin {
domain_name = var.artifact_bucket.bucket_regional_domain_name
origin_id = "S3-PersonalMeasure-${var.environment}"
origin_path = "/${var.environment}/webroot"
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.origin_access_identity.cloudfront_access_identity_path
}
}
enabled = true
is_ipv6_enabled = true
comment = "Personal Measure ${var.environment} distribution."
default_root_object = "/index.html"
logging_config {
include_cookies = false
bucket = var.artifact_bucket.bucket_domain_name
prefix = "${var.environment}/logs/cloudfront"
}
aliases = [local.app_domain_name]
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD", "OPTIONS"]
target_origin_id = "S3-PersonalMeasure-${var.environment}"
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 60 * 60 * 24 * 365 # cache for a year
max_ttl = 60 * 60 * 24 * 365 # cache for a year
compress = true
viewer_protocol_policy = "redirect-to-https"
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
price_class = "PriceClass_100" # US and Canada only
restrictions {
geo_restriction {
restriction_type = "none"
}
}
tags = {
Environment = local.environment_name
}
viewer_certificate {
acm_certificate_arn = data.terraform_remote_state.jdbsoft.outputs.aws_acm_certificate_jdbsoft_us_east_1.arn
ssl_support_method = "sni-only"
}
}

View File

@ -0,0 +1,25 @@
resource "aws_route53_record" "app_domain" {
zone_id = data.terraform_remote_state.jdbsoft.outputs.aws_route53_zone_jdbsoft.zone_id
name = local.app_domain_name
type = "A"
alias {
name = aws_cloudfront_distribution.s3_distribution.domain_name
zone_id = aws_cloudfront_distribution.s3_distribution.hosted_zone_id
evaluate_target_health = false
}
depends_on = [aws_cloudfront_distribution.s3_distribution ]
}
resource "aws_route53_record" "api_domain" {
zone_id = data.terraform_remote_state.jdbsoft.outputs.aws_route53_zone_jdbsoft.zone_id
name = local.api_domain_name
type = "A"
alias {
name = data.terraform_remote_state.jdbsoft.outputs.aws_lb_jdbsoft.dns_name
zone_id = data.terraform_remote_state.jdbsoft.outputs.aws_lb_jdbsoft.zone_id
evaluate_target_health = false
}
}

View File

@ -0,0 +1,75 @@
resource "aws_secretsmanager_secret" "pmapi" {
name = "${local.environment_name}-Config"
tags = { Environment = local.environment_name }
}
resource "aws_ecs_task_definition" "pmapi" {
family = local.environment_name
network_mode = "bridge"
requires_compatibilities = ["EC2"]
execution_role_arn = aws_iam_role.ecs_task.arn
# See https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerDefinition.html
container_definitions = jsonencode([
{
name = local.environment_name
image = "${var.ecr_repo.repository_url}:${data.external.git_describe.result.version}"
cpu = 128
memory = 128
memoryReservation = 32
environment = [
{
name = "PORT"
value = "80"
}
]
portMappings = [
{
protocol = "tcp"
containerPort = 80
}
]
secrets = [
{
name = "AUTH_SECRET"
description = "Auth secret used to hash and salt passwords."
valueFrom = "${aws_secretsmanager_secret.pmapi.arn}:authSecret::"
},
{
name = "DB_CONN_STRING"
description = "Connection string with user credentials."
valueFrom = "${aws_secretsmanager_secret.pmapi.arn}:dbConnString::"
},
{
name = "KNOWN_ORIGINS"
description = "Connection string with user credentials."
valueFrom = "${aws_secretsmanager_secret.pmapi.arn}:knownOrigins::"
}
]
}
])
tags = {
Name = local.api_domain_name
Environment = local.environment_name
}
}
resource "aws_ecs_service" "pmapi" {
name = local.environment_name
cluster = data.terraform_remote_state.jdbsoft.outputs.aws_ecs_cluster_ortis.id
task_definition = aws_ecs_task_definition.pmapi.arn
desired_count = 1
launch_type = "EC2"
load_balancer {
target_group_arn = aws_lb_target_group.pmapi.arn
container_name = local.environment_name
container_port = 80
}
tags = {
Name = local.api_domain_name
Environment = local.environment_name
}
}

View File

@ -0,0 +1,69 @@
resource "aws_iam_role" "ecs_task" {
name = "${local.environment_name}-EcsTaskRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Sid = ""
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
inline_policy {
name = "AllowSecretsAccessForPmApiTasks"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue",
"kms:Decrypt"
]
Resource = [
aws_secretsmanager_secret.pmapi.arn
]
}
]
})
}
inline_policy {
name = "AllowAccessToEcrForPmApiTasks"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ecr:GetAuthorizationToken"
]
Resource = [ "*" ]
},
{
Effect = "Allow"
Action = [
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability",
"ecr:DescribeImages",
"ecr:GetDownloadUrlForLayer"
]
Resource = [
var.ecr_repo.arn
]
}
]
})
}
tags = {
Name = "PersonalMeasure-EcsTaskRole"
Environment = local.environment_name
}
}

View File

@ -0,0 +1,43 @@
resource "aws_lb_target_group" "pmapi" {
name = "${local.environment_name}-${substr(uuid(), 0, 2)}"
port = 80
protocol = "HTTP"
target_type = "instance"
vpc_id = data.terraform_remote_state.jdbsoft.outputs.aws_vpc_jdbsoft.id
health_check {
enabled = true
matcher = "200"
path = "/v0/version"
}
lifecycle {
create_before_destroy = true
ignore_changes = [name]
}
tags = {
Name = local.api_domain_name
Environment = local.environment_name
}
}
resource "aws_lb_listener_rule" "pmapi" {
listener_arn = data.terraform_remote_state.jdbsoft.outputs.aws_lb_listener_https.arn
action {
type = "forward"
target_group_arn = aws_lb_target_group.pmapi.arn
}
condition {
host_header {
values = [ local.api_domain_name ]
}
}
tags = {
Name = "${local.api_domain_name} HTTPS"
Environment = local.environment_name
}
}

View File

@ -0,0 +1,34 @@
### Variables
variable "environment" {
description = "The short name of this deployed environment. For example: 'dev' or 'prod'. This short name will be used to name resources (CloudFront distributions, etc.)"
}
variable "artifact_bucket" {
description = "The aws_s3_bucket object representing the artifact bucket where deployed artifacts, logs, etc. live."
}
variable "ecr_repo" {
description = "ECR repository information."
}
locals {
environment_name = "PersonalMeasure-${var.environment}"
app_domain_name = "pm${var.environment == "prod" ? "" : "-${var.environment}"}.jdb-software.com"
api_domain_name = "pmapi${var.environment == "prod" ? "" : "-${var.environment}"}.jdb-software.com"
}
data "external" "git_describe" {
program = ["sh", "-c", "git describe | xargs printf '{\"version\": \"%s\"}'"]
}
data "terraform_remote_state" "jdbsoft" {
backend = "s3"
config = {
bucket = "operations.jdb-software.com"
region = "us-west-2"
key = "terraform/operations.tfstate"
dynamodb_table = "terraform-state-lock.jdb-software.com"
}
}

View File

@ -0,0 +1,8 @@
resource "aws_ecr_repository" "personal_measure_api" {
name = "personal_measure_api"
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}

View File

@ -0,0 +1,34 @@
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "personal_measure" {
bucket = var.app_root_url
acl = "log-delivery-write"
}
module "dev_env" {
source = "./deployed_env"
environment = "dev"
artifact_bucket = aws_s3_bucket.personal_measure
ecr_repo = aws_ecr_repository.personal_measure_api
}
module "prod_env" {
source = "./deployed_env"
environment = "prod"
artifact_bucket = aws_s3_bucket.personal_measure
ecr_repo = aws_ecr_repository.personal_measure_api
}
data "aws_iam_policy_document" "cloudfront_access_policy" {
source_json = "${module.dev_env.oai_access_policy.json}"
override_json = "${module.prod_env.oai_access_policy.json}"
}
resource "aws_s3_bucket_policy" "personal_measure" {
bucket = aws_s3_bucket.personal_measure.id
policy = data.aws_iam_policy_document.cloudfront_access_policy.json
}

View File

@ -0,0 +1,8 @@
terraform {
backend "s3" {
bucket = "pm.jdb-software.com"
region = "us-west-2"
key = "terraform.tfstate"
dynamodb_table = "terraform-state-lock.jdb-software.com"
}
}

63
operations/update-version.sh Executable file
View File

@ -0,0 +1,63 @@
#!/bin/bash
#
# Script to update the version number, commit the changes to the version files,
# and tag the new commit.
set -e
origDir=$(pwd)
rootDir=$(git rev-parse --show-toplevel)
cd "$rootDir"
currentBranch=$(git rev-parse --abbrev-ref HEAD)
if [ "$currentBranch" != "develop" ]; then
printf "You are currently on the '%s' branch. Is this intended (yes/no)? " "$currentBranch"
read -r confirmation
if [ "$confirmation" != "yes" ]; then exit 1; fi
fi
lastVersion=$(jq -r .version web/package.json)
printf "Last version: %s\n" "$lastVersion"
printf "New version: "
read -r newVersion
printf "New version will be \"%s\". Is this correct (yes/no)? " "$newVersion"
read -r confirmation
if [ "$confirmation" != "yes" ]; then
printf "\n"
"$origDir/$0"
exit
fi
printf ">> Updating /web/package.json with \"version\": \"%s\"\n" "$newVersion"
printf "jq \".version = \\\"%s\\\"\" web/package.json > temp.json\n" "$newVersion"
jq ".version = \"${newVersion}\"" web/package.json > temp.json
printf "mv temp.json web/package.json\n"
mv temp.json web/package.json
printf ">> Updating /web/package-lock.json with \"version\": \"%s\"\n" "$newVersion"
printf "jq \".version = \\\"%s\\\"\" web/package-lock.json > temp.json\n" "$newVersion"
jq ".version = \"${newVersion}\"" web/package-lock.json > temp.json
printf "mv temp.json web/package-lock.json\n"
mv temp.json web/package-lock.json
printf ">> Updating /api/src/main/nim/personal_measure_apipkg/version.nim with PM_API_VERSION* = \"%s\"" "$newVersion"
printf "sed -i \"s/%s/%s/\" api/src/main/nim/personal_measure_apipkg/version.nim" "$lastVersion" "$newVersion"
sed -i "s/${lastVersion}/${newVersion}/" api/src/main/nim/personal_measure_apipkg/version.nim
printf ">> Updating /api/personal_measure_api.nimble with version = \"%s\"" "$newVersion"
printf "sed -i \"s/%s/%s/\" api/personal_measure_api.nimble" "$lastVersion" "$newVersion"
sed -i "s/${lastVersion}/${newVersion}/" api/personal_measure_api.nimble
printf ">> Committing new version.\n"
printf "git add web/package.json web/package-lock.json api/src/main/nim/personal_measure_apipkg/version.nim"
git add web/package.json web/package-lock.json api/src/main/nim/personal_measure_apipkg/version.nim api/personal_measure_api.nimble
printf "git commit -m \"Update package version to %s\"\n" "$newVersion"
git commit -m "Update package version to ${newVersion}"
printf ">> Tagging commit.\n"
printf "git tag -m \"Version %s\" \"%s\"\n" "$newVersion" "$newVersion"
git tag -m "Version ${newVersion}" "${newVersion}"

4
web/.env.development Normal file
View File

@ -0,0 +1,4 @@
NODE_ENV=production
VUE_APP_PM_API_BASE=https://pmapi-dev.jdb-software.com/v0
VUE_APP_LOG_LEVEL=INFO
VUE_APP_API_LOG_LEVEL=ERROR

View File

@ -1,3 +0,0 @@
VUE_APP_PM_API_BASE=https://personal-measure.jdb-labs.com/api
VUE_APP_LOG_LEVEL=INFO
VUE_APP_API_LOG_LEVEL=ERROR

3
web/.env.production Normal file
View File

@ -0,0 +1,3 @@
VUE_APP_PM_API_BASE=https://pmapi.jdb-software.com/v0
VUE_APP_LOG_LEVEL=INFO
VUE_APP_API_LOG_LEVEL=ERROR

View File

@ -1,24 +1,5 @@
API_ADDR='localhost:8080'
API_LOG_LEVEL='WARN'
LOG_LEVEL='TRACE'
build-dev:
npm run build-dev
build: build:
npm run build npm run build-${TARGET_ENV}
serve: serve:
VUE_APP_PM_API_BASE=/api \
VUE_APP_API_LOG_LEVEL=${API_LOG_LEVEL} \
VUE_APP_LOG_LEVEL=${LOG_LEVEL} \
npm run serve npm run serve
serve-dev: build-dev
(cd dist && npx live-server . --port=8080 --entry-file=index.html --proxy=/api:http://localhost:8081/api --no-browser)
serve-ssl: build-dev
(cd dist && \
(local-ssl-proxy --source=8443 --target=8080 & \
echo `pwd` && \
npx live-server . --port=8080 --entry-file=index.html --proxy=/api:http://localhost:8081/api --no-browser))

5714
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +1,64 @@
{ {
"name": "personal-measure-web", "name": "personal-measure-web",
"version": "0.1.0", "version": "0.11.0",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "npx servor dist",
"build": "vue-cli-service build", "build-prod": "vue-cli-service build --mode production",
"build-dev": "vue-cli-service build --mode development", "build-dev": "vue-cli-service build --mode development",
"lint": "vue-cli-service lint", "lint": "vue-cli-service lint",
"test:unit": "vue-cli-service test:unit" "test:unit": "vue-cli-service test:unit"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.15", "@fortawesome/fontawesome-svg-core": "^1.2.31",
"@fortawesome/free-solid-svg-icons": "^5.7.2", "@fortawesome/free-solid-svg-icons": "^5.15.0",
"@fortawesome/vue-fontawesome": "^0.1.5", "@fortawesome/vue-fontawesome": "^0.1.10",
"@types/js-cookie": "^2.2.1", "@types/js-cookie": "^2.2.6",
"@types/jwt-decode": "^2.2.1", "@types/jwt-decode": "^2.2.1",
"@types/lodash.assign": "^4.2.6", "@types/lodash.assign": "^4.2.6",
"@types/lodash.findindex": "^4.6.6", "@types/lodash.findindex": "^4.6.6",
"@types/lodash.merge": "^4.6.5", "@types/lodash.merge": "^4.6.6",
"apexcharts": "^3.6.5", "@types/lodash.omit": "^4.5.6",
"axios": "^0.18.0", "apexcharts": "^3.21.0",
"js-cookie": "^2.2.0", "axios": "^0.18.1",
"js-cookie": "^2.2.1",
"jwt-decode": "^2.2.0", "jwt-decode": "^2.2.0",
"keen-ui": "^1.1.2", "keen-ui": "^1.3.1",
"lodash.assign": "^4.2.0", "lodash.assign": "^4.2.0",
"lodash.findindex": "^4.6.0", "lodash.findindex": "^4.6.0",
"lodash.keyby": "^4.6.0", "lodash.keyby": "^4.6.0",
"lodash.merge": "^4.6.1", "lodash.merge": "^4.6.2",
"moment": "^2.24.0", "lodash.omit": "^4.5.0",
"register-service-worker": "^1.5.2", "moment": "^2.29.0",
"vue": "^2.6.6", "register-service-worker": "^1.7.1",
"vue-apexcharts": "^1.3.2", "vue": "^2.6.12",
"vue-apexcharts": "^1.6.0",
"vue-class-component": "^6.0.0", "vue-class-component": "^6.0.0",
"vue-property-decorator": "^7.0.0", "vue-property-decorator": "^7.0.0",
"vue-router": "^3.0.1", "vue-router": "^3.4.5",
"vuejs-smart-table": "0.0.3", "vuejs-smart-table": "0.0.3",
"vuex": "^3.0.1", "vuex": "^3.5.1",
"vuex-module-decorators": "^0.9.8" "vuex-module-decorators": "^0.9.11"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^23.1.4", "@types/jest": "^23.1.4",
"@types/lodash.keyby": "^4.6.6", "@types/lodash.keyby": "^4.6.6",
"@vue/cli-plugin-babel": "^3.4.0", "@vue/cli-plugin-babel": "^3.12.1",
"@vue/cli-plugin-pwa": "^3.4.0", "@vue/cli-plugin-pwa": "^3.12.1",
"@vue/cli-plugin-typescript": "^3.4.0", "@vue/cli-plugin-typescript": "^3.12.1",
"@vue/cli-plugin-unit-jest": "^3.7.0", "@vue/cli-plugin-unit-jest": "^3.12.1",
"@vue/cli-service": "^3.5.3", "@vue/cli-service": "^3.12.1",
"@vue/test-utils": "^1.0.0-beta.20", "@vue/test-utils": "^1.1.0",
"babel-core": "7.0.0-bridge.0", "babel-core": "7.0.0-bridge.0",
"lint-staged": "^8.1.0", "lint-staged": "^8.2.1",
"live-server": "^1.2.1", "live-server": "^1.2.1",
"node-sass": "^4.12.0", "node-sass": "^4.14.1",
"sass-loader": "^7.1.0", "sass-loader": "^7.3.1",
"servor": "^4.0.2",
"ts-jest": "^23.0.0", "ts-jest": "^23.0.0",
"typescript": "^3.0.0", "typescript": "^3.9.7",
"vue-cli-plugin-webpack-bundle-analyzer": "^1.3.0", "vue-cli-plugin-webpack-bundle-analyzer": "^1.4.0",
"vue-template-compiler": "^2.5.21" "vue-template-compiler": "^2.6.12"
}, },
"gitHooks": { "gitHooks": {
"pre-commit": "lint-staged" "pre-commit": "lint-staged"

View File

@ -2,6 +2,7 @@
<div id="app"> <div id="app">
<NavBar></NavBar> <NavBar></NavBar>
<router-view class=main /> <router-view class=main />
<span id="personal-measure-version" hidden>{{ version }}</span>
</div> </div>
</template> </template>
<script lang="ts" src="./app.ts"></script> <script lang="ts" src="./app.ts"></script>

View File

@ -13,6 +13,7 @@ const logger = logService.getLogger('/app');
}) })
export default class App extends Vue { export default class App extends Vue {
public version = process.env.PM_VERSION;
private apiLogAppender!: ApiLogAppender; private apiLogAppender!: ApiLogAppender;
private consoleLogAppender!: ConsoleLogAppender; private consoleLogAppender!: ConsoleLogAppender;

View File

@ -2,19 +2,40 @@
<fieldset> <fieldset>
<div> <div>
<label for=measureType>Type</label> <label for=measureType>Type</label>
<span v-if=measureExists>{{value.type}}</span>
<select <select
:disabled=disabled :disabled=disabled
name=measureType name=measureType
v-if="!measureExists"
v-model=value.type> v-model=value.type>
<option value=simple>Simple</option> <option value=simple>Simple</option>
<option value=list>List</option> <option value=text>Text</option>
</select> </select>
</div> </div>
<div> <div>
<label for=measureIsVisible>Show by default.</label> <label for=measureIsVisible>Show by default.</label>
<input type=checkbox v-model=value.isVisible :disabled=disabled /> <input type=checkbox v-model=value.isVisible :disabled=disabled />
</div> </div>
<!--<ListMeasureConfigForm :config=config v-show="config.type === 'list'"/>--> <div>
<label for=timestampDisplayFormat>Timestamp Format</label>
<select
v-on:change=formatSelectionChanged
:disabled=disabled
v-model=selectedFormat
name=timestampDisplayFormat>
<option v-for="fmtStr in formatStrings"
:value=fmtStr>{{now.format(fmtStr)}}</option>
<option value="custom">Custom</option>
</select>
</div>
<div v-if="selectedFormat === 'custom'">
<label for=timestampCustomDisplayFormat>
Custom Timestamp Format
(<a target="_blank" href="https://momentjs.com/docs/#/displaying/format/">see formatting options</a>)
</label>
<input type=text v-model=value.timestampDisplayFormat />
</div>
<TextMeasureConfigForm v-model=value v-show="value.type === 'text'" :disabled=disabled />
</fieldset> </fieldset>
</template> </template>
<script lang=ts src=./measure-config-form.ts></script> <script lang=ts src=./measure-config-form.ts></script>

View File

@ -0,0 +1,10 @@
<template>
<div>
<label for=textEntryShowTimestamp>Show Timestamps.</label>
<input name=textEntryShowTimestamp
:disabled=disabled
type=checkbox
v-model=value.showTimestamp />
</div>
</template>
<script lang=ts src=./text-measure-config-form.ts></script>

View File

@ -1,17 +1,60 @@
import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator'; import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator';
import { logService } from '@/services/logging'; import { logService } from '@/services/logging';
import { Measure, MeasureConfig } from '@/models'; import { Measure, MeasureConfig } from '@/models';
import TextMeasureConfigForm from './TextMeasureConfigForm.vue';
import moment from 'moment';
@Component({}) @Component({
components: {
TextMeasureConfigForm
}
})
export class MeasureConfigForm extends Vue { export class MeasureConfigForm extends Vue {
@Prop({}) public value!: MeasureConfig; @Prop({}) public value!: MeasureConfig;
@Prop({}) public disabled: boolean = false; @Prop({}) public disabled: boolean = false;
@Prop({}) public measureExists!: boolean;
public now = moment();
public formatStrings = [
'l',
'L',
'll',
'LL',
'lll',
'LLL',
'llll',
'LLLL',
'Y-MM-DD',
'Y-MM-DDTHH:mm',
'Y-MM-DDTHH:mm:ss',
'Y-MM-DDTHH:mm:ss.SSSZZ',
'MM/DD',
'MMM Do',
'HH:mm',
'hh:mmA'
];
private selectedFormat: string = 'l';
@Watch('value', { immediate: true, deep: true }) @Watch('value', { immediate: true, deep: true })
@Emit('input') @Emit('input')
private onConfigChanged(newVal: MeasureConfig, oldVal: MeasureConfig) { private onConfigChanged(newVal: MeasureConfig, oldVal: MeasureConfig) {
return newVal; return newVal;
} }
private formatSelectionChanged() {
if (this.selectedFormat !== 'custom') {
this.value.timestampDisplayFormat = this.selectedFormat;
}
}
private mounted() {
if (this.formatStrings.includes(this.value.timestampDisplayFormat)) {
this.selectedFormat = this.value.timestampDisplayFormat;
} else {
this.selectedFormat = 'custom';
}
}
} }
export default MeasureConfigForm; export default MeasureConfigForm;

View File

@ -0,0 +1,17 @@
import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator';
import { logService } from '@/services/logging';
import { Measure, MeasureConfig, TextMeasureConfig } from '@/models';
@Component({})
export class TextMeasureConfigForm extends Vue {
@Prop({}) public value!: MeasureConfig;
@Prop({}) public disabled: boolean = false;
@Watch('value', { immediate: true, deep: true })
@Emit('input')
private onConfigChanged(newVal: TextMeasureConfig, oldVal: TextMeasureConfig) {
return newVal;
}
}
export default TextMeasureConfigForm;

View File

@ -2,6 +2,8 @@
<div> <div>
<SimpleDetails v-if="measure.config.type === 'simple'" <SimpleDetails v-if="measure.config.type === 'simple'"
:measure=measure :measurements=measurements /> :measure=measure :measurements=measurements />
<TextDetails v-if="measure.config.type === 'text'"
:measure=measure :measurements=measurements />
</div> </div>
</template> </template>
<script lang="ts" src="./measure-details.ts"></script> <script lang="ts" src="./measure-details.ts"></script>

View File

@ -15,7 +15,6 @@
</tr> </tr>
</tbody> </tbody>
</v-table> </v-table>
<SimpleEntry :measure=measure v-model=
</div> </div>
</template> </template>
<script lang="ts" src="./simple-details.ts"></script> <script lang="ts" src="./simple-details.ts"></script>

View File

@ -0,0 +1,22 @@
<template>
<div class=text-details>
<v-table :data=measurementTableData>
<thead slot=head>
<tr>
<v-th
v-if="measure.config.showTimestamp"
sortKey=tsSort
defaultSort=asc>Timestamp</v-th>
<v-th sortKey=value>{{measure.name}}</v-th>
</tr>
</thead>
<tbody slot=body slot-scope={displayData} >
<tr v-for="row in displayData" :key="row.id">
<td v-if="measure.config.showTimestamp">{{row.tsDisplay}}</td>
<td>{{row.extData.entry}}</td>
</tr>
</tbody>
</v-table>
</div>
</template>
<script lang=ts src=./text-details.ts></script>

View File

@ -1,9 +1,13 @@
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models'; import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
import SimpleDetails from './SimpleDetails.vue'; import SimpleDetails from './SimpleDetails.vue';
import TextDetails from './TextDetails.vue';
@Component({ @Component({
components: { SimpleDetails } components: {
SimpleDetails,
TextDetails
}
}) })
export class MeasureDetails extends Vue { export class MeasureDetails extends Vue {
@Prop() private measure!: Measure<MeasureConfig>; @Prop() private measure!: Measure<MeasureConfig>;

View File

@ -1,9 +1,9 @@
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models'; import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
import moment from 'moment';
import assign from 'lodash.assign'; import assign from 'lodash.assign';
import { library } from '@fortawesome/fontawesome-svg-core'; import { library } from '@fortawesome/fontawesome-svg-core';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons'; import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { byTimestampComparator, formatTS } from '@/util';
library.add(faPencilAlt); library.add(faPencilAlt);
@ -12,28 +12,29 @@ export class SimpleDetails extends Vue {
@Prop() private measure!: Measure<MeasureConfig>; @Prop() private measure!: Measure<MeasureConfig>;
@Prop() private measurements!: Array<Measurement<MeasurementMeta>>; @Prop() private measurements!: Array<Measurement<MeasurementMeta>>;
private newMeasurement;
private moment = moment;
private chartOptions = { private chartOptions = {
markers: { size: 6 },
noData: { text: 'no data', noData: { text: 'no data',
style: { fontSize: '18px' } }, style: { fontSize: '18px' } },
stroke: { curve: 'smooth' }, stroke: { curve: 'straight' },
xaxis: { type: 'datetime' } xaxis: { type: 'datetime' }
}; };
private get measurementChartData(): ApexAxisChartSeries { private get measurementChartData(): ApexAxisChartSeries {
const measurementData = this.measurements || []; const measurementData = this.measurements.slice() || [];
return [{ return [{
name: this.measure.name, name: this.measure.name,
data: measurementData.map((m) => ({ x: m.timestamp.toISOString(), y: m.value })) data: measurementData
.sort(byTimestampComparator)
.map((m) => ({ x: m.timestamp.toISOString(), y: m.value }))
}]; }];
} }
private get measurementTableData() { private get measurementTableData() {
return (this.measurements || []).map((m) => { return (this.measurements || []).map((m) => {
return assign({}, m, { return assign({}, m, {
tsDisplay: moment(m.timestamp).format('MMM Do, HH:mm'), tsDisplay: formatTS(this.measure, m),
tsSort: m.timestamp.toISOString() tsSort: m.timestamp.toISOString()
}); });
}); });

View File

@ -0,0 +1,22 @@
import { Component, Prop, Vue } from 'vue-property-decorator';
import assign from 'lodash.assign';
import { Measure, Measurement, TextMeasureConfig, TextMeasurementMeta } from '@/models';
import { formatTS } from '@/util';
@Component({})
export class TextDetails extends Vue {
@Prop() private measure!: Measure<TextMeasureConfig>;
@Prop() private measurements!: Array<Measurement<TextMeasurementMeta>>;
private get measurementTableData() {
return (this.measurements || []).map((m) => {
return assign({}, m, {
tsDisplay: formatTS(this.measure, m),
tsSort: m.timestamp.toISOString()
});
});
}
}
export default TextDetails;

View File

@ -1,6 +0,0 @@
<template>
<ul>
<li v-for="m in top5">{{m.extData.entry}}</li>
</ul>
</template>
<script lang="ts" src="./list-summary.ts"></script>

View File

@ -1,11 +1,11 @@
<template> <template>
<div v-if="measure.config.isVisible" class="measure-summary" :data-name="'measure-' + measure.slug"> <div v-if="measure.config.isVisible" v-bind:key="measure.slug" class="measure-summary" :data-name="'measure-' + measure.slug">
<h2><router-link <h2><router-link
:to="'/measures/' + measure.slug"> :to="'/measures/' + measure.slug">
{{measure.name}}</router-link></h2> {{measure.name}}</router-link></h2>
<SimpleSummaryGraph v-if="measure.config.type === 'simple'" <SimpleSummaryGraph v-if="measure.config.type === 'simple'"
:measure=measure :measurements=measurements /> :measure=measure :measurements=measurements />
<ListSummary v-if="measure.config.type === 'list'" <TextSummary v-if="measure.config.type === 'text'"
:measure=measure :measurements=measurements /> :measure=measure :measurements=measurements />
</div> </div>
</template> </template>

View File

@ -0,0 +1,13 @@
<template>
<ul>
<li
v-for="m in top5"
v-bind:class="{ 'show-timestamp': measure.config.showTimestamp,
'full-timestamp': !withinLastYear }">
<span class=timestamp>{{formatDate(m.timestamp)}}</span>
<span class=entry>{{m.extData.entry}}</span>
</li>
</ul>
</template>
<script lang="ts" src="./text-summary.ts"></script>
<style scoped lang="scss" src="./text-summary.scss"></script>

View File

@ -1,16 +0,0 @@
import { Component, Prop, Vue } from 'vue-property-decorator';
import { Measure, ListMeasureConfig, Measurement, ListMeasurementMeta } from '@/models';
@Component
export class ListSummary extends Vue {
@Prop() private measure!: Measure<ListMeasureConfig>;
@Prop() private measurements!: Array<Measurement<ListMeasurementMeta>>;
private top5(): Array<Measurement<ListMeasurementMeta>> {
return this.measurements
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, 5);
}
}
export default ListSummary;

View File

@ -1,12 +1,12 @@
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models'; import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
import { measurementStore } from '@/store'; import { measurementStore } from '@/store';
import ListSummary from './ListSummary.vue'; import TextSummary from './TextSummary.vue';
import SimpleSummaryGraph from './SimpleSummaryGraph.vue'; import SimpleSummaryGraph from './SimpleSummaryGraph.vue';
@Component({ @Component({
components: { components: {
ListSummary, TextSummary,
SimpleSummaryGraph SimpleSummaryGraph
} }
}) })

View File

@ -9,18 +9,20 @@ export class SimpleSummaryGraph extends Vue {
private chartOptions = { private chartOptions = {
chart: { sparkline: { enabled: true } }, chart: { sparkline: { enabled: true } },
grid: { padding: { top: 20 }}, grid: { padding: { top: 20 }},
stroke: { curve: 'smooth' }, stroke: { curve: 'straight' },
noData: { text: 'no data', noData: { text: 'no data',
style: { fontSize: '18px' } }, style: { fontSize: '18px' } },
xaxis: { type: 'datetime' } xaxis: { type: 'datetime' }
}; };
private get measurementData(): ApexAxisChartSeries { private get measurementData(): ApexAxisChartSeries {
const measurementData = this.measurements || []; const measurementData = this.measurements.slice() || [];
return [{ return [{
name: this.measure.name, name: this.measure.name,
data: measurementData.map((m) => ({ x: m.timestamp.toISOString(), y: m.value })) data: measurementData
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
.map((m) => ({ x: m.timestamp.toISOString(), y: m.value }))
}]; }];
} }
} }

View File

@ -0,0 +1,39 @@
@import '~@/styles/vars';
ul {
list-style: none;
padding: 0.5rem 0;
li {
span {
display: inline-block;
vertical-align: bottom;
&.timestamp {
color: $color2;
font-weight: bold;
}
&.entry {
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&:not(.show-timestamp) {
span.timestamp { display: none; }
span.entry { width: 100%; }
}
&.show-timestamp {
span.timestamp { width: 5rem; }
span.entry { width: calc(100% - 5rem); }
}
&.show-timestamp.full-timestamp {
span.timestamp { width: 6rem; }
span.entry { width: calc(100% - 6rem); }
}
}
}

View File

@ -0,0 +1,33 @@
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import moment from 'moment';
import { Measure, TextMeasureConfig, Measurement, TextMeasurementMeta } from '@/models';
import { byTimestampComparator } from '@/util';
const YEAR_START = moment().startOf('year');
@Component
export class TextSummary extends Vue {
@Prop() private measure!: Measure<TextMeasureConfig>;
@Prop() private measurements!: Array<Measurement<TextMeasurementMeta>>;
private top5: Array<Measurement<TextMeasurementMeta>> = [];
private withinLastYear: boolean = true;
@Watch('measurements')
private onMeasurementsChanged() {
this.top5 = this.measurements
.slice(0)
.sort(byTimestampComparator)
.slice(0, 5);
this.withinLastYear = this.top5.every((entry) => YEAR_START.isBefore(entry.timestamp));
}
private formatDate(ts: Date) {
if (this.withinLastYear) { return moment(ts).format('MMM. Do'); }
else { return moment(ts).format('YYYY-MM-DD'); }
}
}
export default TextSummary;

View File

@ -2,6 +2,8 @@
<div> <div>
<SimpleEntry v-if="measure.config.type === 'simple'" <SimpleEntry v-if="measure.config.type === 'simple'"
:measure=measure v-model=value /> :measure=measure v-model=value />
<TextEntry v-if="measure.config.type === 'text'"
:measure=measure v-model=value />
</div> </div>
</template> </template>
<script lang="ts" src="./measurement-entry.ts"></script> <script lang="ts" src="./measurement-entry.ts"></script>

View File

@ -2,7 +2,9 @@
<fieldset> <fieldset>
<div> <div>
<label for=timestamp>Timestamp</label> <label for=timestamp>Timestamp</label>
<input type=datetime-local <input
name=timestamp
type=datetime-local
v-model=value.timestamp v-model=value.timestamp
v-show=editTimestamp v-show=editTimestamp
:disabled=disabled /> :disabled=disabled />
@ -12,7 +14,7 @@
</div> </div>
<div> <div>
<label for=measurementValue>{{measure.name}}</label> <label for=measurementValue>{{measure.name}}</label>
<input required type=number v-model=value.value :disabled=disabled /> <input name=measurementValue required type=number step=any v-model.number=value.value :disabled=disabled />
</div> </div>
</fieldset> </fieldset>
</template> </template>

View File

@ -0,0 +1,26 @@
<template>
<fieldset>
<div>
<label for=timestamp>Timestamp</label>
<input
name=timestamp
type=datetime-local
v-model=value.timestamp
v-show=editTimestamp
:disabled=disabled />
<span v-show="!editTimestamp">
now <a href="#" v-on:click.stop.prevent="editTimestamp = true"> (set a time)</a>
</span>
</div>
<div>
<label for=measurementEntry>{{measure.name}}</label>
<input
name=measurementEntry
required
type=text
v-model=value.extData.entry
:disabled=disabled />
</div>
</fieldset>
</template>
<script lang="ts" src="./text-entry.ts"></script>

View File

@ -1,9 +1,13 @@
import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator'; import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator';
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models'; import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
import SimpleEntry from './SimpleEntry.vue'; import SimpleEntry from './SimpleEntry.vue';
import TextEntry from './TextEntry.vue';
@Component({ @Component({
components: { SimpleEntry } components: {
SimpleEntry,
TextEntry
}
}) })
export class MeasurementEntry extends Vue { export class MeasurementEntry extends Vue {
@Prop() private measure!: Measure<MeasureConfig>; @Prop() private measure!: Measure<MeasureConfig>;

View File

@ -5,14 +5,12 @@ import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } fro
export class SimpleEntry extends Vue { export class SimpleEntry extends Vue {
@Prop() public measure!: Measure<MeasureConfig>; @Prop() public measure!: Measure<MeasureConfig>;
@Prop() public value!: Measurement<MeasurementMeta>; @Prop() public value!: Measurement<MeasurementMeta>;
@Prop() public disabled: boolean = false; @Prop() public disabled!: boolean;
private editTimestamp: boolean = false; private editTimestamp: boolean = false;
@Watch('value', { immediate: true, deep: true }) @Watch('value', { immediate: true, deep: true })
@Emit('input') @Emit('input')
private onMeasurementChanged(newVal: Measurement<MeasurementMeta>, oldVal: Measurement<MeasurementMeta>) { private onMeasurementChanged(newVal: Measurement<MeasurementMeta>, oldVal: Measurement<MeasurementMeta>) {
newVal.extData.measureType = 'simple' as MeasureType;
if (typeof(newVal.value) === 'string' ) { if (typeof(newVal.value) === 'string' ) {
newVal.value = parseInt(newVal.value, 10); newVal.value = parseInt(newVal.value, 10);
} }

View File

@ -0,0 +1,13 @@
import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator';
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
@Component({})
export class TextEntry extends Vue {
@Prop() public measure!: Measure<MeasureConfig>;
@Prop() public value!: Measurement<MeasurementMeta>;
@Prop() public disabled!: boolean;
private editTimestamp: boolean = false;
}
export default TextEntry;

8
web/src/models.d.ts vendored
View File

@ -1,4 +1,4 @@
export enum MeasureType { List = 'list', Simple = 'simple' } export enum MeasureType { Text = 'text', Simple = 'simple' }
export interface ApiToken { export interface ApiToken {
id: string; id: string;
@ -17,9 +17,10 @@ export interface LoginSubmit {
export interface MeasureConfig { export interface MeasureConfig {
type: MeasureType; type: MeasureType;
isVisible: boolean; isVisible: boolean;
timestampDisplayFormat: string;
} }
export interface ListMeasureConfig extends MeasureConfig { export interface TextMeasureConfig extends MeasureConfig {
showTimestamp: boolean; showTimestamp: boolean;
} }
@ -33,10 +34,9 @@ export interface Measure<C extends MeasureConfig> {
} }
export interface MeasurementMeta { export interface MeasurementMeta {
measureType: MeasureType;
} }
export interface ListMeasurementMeta extends MeasurementMeta { export interface TextMeasurementMeta extends MeasurementMeta {
entry: string; entry: string;
} }

View File

@ -7,6 +7,8 @@ import Measure from '@/views/Measure.vue';
import Measures from '@/views/Measures.vue'; import Measures from '@/views/Measures.vue';
import NewMeasure from '@/views/NewMeasure.vue'; import NewMeasure from '@/views/NewMeasure.vue';
import NewMeasurement from '@/views/NewMeasurement.vue'; import NewMeasurement from '@/views/NewMeasurement.vue';
import DeleteMeasure from '@/views/DeleteMeasure.vue';
import EditMeasure from '@/views/EditMeasure.vue';
import NotFound from '@/views/NotFound.vue'; import NotFound from '@/views/NotFound.vue';
import QuickPanels from '@/views/QuickPanels.vue'; import QuickPanels from '@/views/QuickPanels.vue';
import UserAccount from '@/views/UserAccount.vue'; import UserAccount from '@/views/UserAccount.vue';
@ -68,6 +70,16 @@ const router = new Router({
name: 'new-measurement', name: 'new-measurement',
component: NewMeasurement component: NewMeasurement
}, },
{
path: '/delete/measure/:slug',
name: 'delete-measure',
component: DeleteMeasure
},
{
path: '/edit/measure/:slug',
name: 'edit-measure',
component: EditMeasure
},
{ {
path: '*', path: '*',
name: 'not-found', name: 'not-found',

View File

@ -1,6 +1,5 @@
import { LogLevel } from './log-message'; import { LogLevel } from './log-message';
import Logger from './logger'; import Logger from './logger';
import { default as Axios, AxiosInstance } from 'axios';
const ROOT_LOGGER_NAME = 'ROOT'; const ROOT_LOGGER_NAME = 'ROOT';
@ -8,7 +7,6 @@ const ROOT_LOGGER_NAME = 'ROOT';
export class LogService { export class LogService {
private loggers: { [key: string]: Logger }; private loggers: { [key: string]: Logger };
private http: AxiosInstance = Axios.create();
public get ROOT_LOGGER() { public get ROOT_LOGGER() {
return this.loggers[ROOT_LOGGER_NAME]; return this.loggers[ROOT_LOGGER_NAME];

View File

@ -123,6 +123,11 @@ export class PmApiClient {
return resp.data; return resp.data;
} }
public async updateMeasure<T extends MeasureConfig>(measure: Measure<T>): Promise<Measure<T>> {
const resp = await this.http.post(`/measures/${measure.slug}`, measure);
return resp.data;
}
public async deleteMeasure(slug: string): Promise<boolean> { public async deleteMeasure(slug: string): Promise<boolean> {
const resp = await this.http.delete(`/measures/${slug}`); const resp = await this.http.delete(`/measures/${slug}`);
return true; return true;
@ -131,7 +136,7 @@ export class PmApiClient {
public async getMeasurements(measureSlug: string) public async getMeasurements(measureSlug: string)
: Promise<Array<Measurement<MeasurementMeta>>> { : Promise<Array<Measurement<MeasurementMeta>>> {
const resp = await this.http.get(`/measure/${measureSlug}`); const resp = await this.http.get(`/measurements/${measureSlug}`);
return resp.data.map(this.fromMeasurementDTO); return resp.data.map(this.fromMeasurementDTO);
} }
@ -141,7 +146,7 @@ export class PmApiClient {
: Promise<Measurement<MeasurementMeta>> { : Promise<Measurement<MeasurementMeta>> {
const resp = await this.http.post( const resp = await this.http.post(
`/measure/${measureSlug}`, `/measurements/${measureSlug}`,
this.toMeasurementDTO(measurement)); this.toMeasurementDTO(measurement));
return this.fromMeasurementDTO(resp.data); return this.fromMeasurementDTO(resp.data);
} }
@ -152,7 +157,7 @@ export class PmApiClient {
: Promise<Measurement<MeasurementMeta>> { : Promise<Measurement<MeasurementMeta>> {
const resp = await this.http const resp = await this.http
.get(`/measure/${measureSlug}/${measurementId}`); .get(`/measurements/${measureSlug}/${measurementId}`);
return this.fromMeasurementDTO(resp.data); return this.fromMeasurementDTO(resp.data);
} }
@ -162,7 +167,7 @@ export class PmApiClient {
: Promise<Measurement<MeasurementMeta>> { : Promise<Measurement<MeasurementMeta>> {
const resp = await this.http.put( const resp = await this.http.put(
`/measure/${measureSlug}/${measurement.id}`, `/measurements/${measureSlug}/${measurement.id}`,
this.toMeasurementDTO(measurement)); this.toMeasurementDTO(measurement));
return this.fromMeasurementDTO(resp.data); return this.fromMeasurementDTO(resp.data);
} }
@ -173,7 +178,7 @@ export class PmApiClient {
: Promise<boolean> { : Promise<boolean> {
const resp = await this.http const resp = await this.http
.delete(`/measure/${measureSlug}/${measurementId}`); .delete(`/measurements/${measureSlug}/${measurementId}`);
return true; return true;
} }

View File

@ -32,7 +32,7 @@ export class AuthStoreModule extends VuexModule {
// this should be guaranteed by the server (redirect HTTP -> HTTPS) // this should be guaranteed by the server (redirect HTTP -> HTTPS)
// but we'll do a sanity check just to make sure. // but we'll do a sanity check just to make sure.
if (window.location.protocol === 'https:' || if (window.location.protocol === 'https:' ||
process.env.NODE_ENV === 'development') { // allow in dev process.env.NODE_ENV === 'development') { // allow http in dev
localStorage.setItem(SESSION_KEY, authToken); localStorage.setItem(SESSION_KEY, authToken);
} }

View File

@ -6,7 +6,9 @@ import {
MutationAction, MutationAction,
VuexModule VuexModule
} from 'vuex-module-decorators'; } from 'vuex-module-decorators';
import assign from 'lodash.assign';
import keyBy from 'lodash.keyby'; import keyBy from 'lodash.keyby';
import omit from 'lodash.omit';
import { User, Measure, MeasureConfig } from '@/models'; import { User, Measure, MeasureConfig } from '@/models';
import api from '@/services/pm-api-client'; import api from '@/services/pm-api-client';
@ -28,13 +30,29 @@ export class MeasureStoreModule extends VuexModule {
} }
@Action({ rawError: true }) @Action({ rawError: true })
public async createMeasure(m: Measure<MeasureConfig>) { public async createMeasure<T extends MeasureConfig>(m: Measure<T>) {
const newMeasure = await api.createMeasure(m); const newMeasure = await api.createMeasure(m);
this.context.commit('SET_MEASURE', newMeasure); this.context.commit('SET_MEASURE', newMeasure);
return newMeasure; return newMeasure;
} }
@Action({ rawError: true })
public async deleteMeasure<T extends MeasureConfig>(m: Measure<T>) {
const delResponse = await api.deleteMeasure(m.slug);
this.context.commit('DELETE_MEASURE', m);
}
@Action({ rawError: true })
public async updateMeasure<T extends MeasureConfig>(m: Measure<T>) {
const updatedMeasure = await api.updateMeasure(m);
return updatedMeasure;
}
@Mutation private SET_MEASURE<T extends MeasureConfig>(measure: Measure<T>) { @Mutation private SET_MEASURE<T extends MeasureConfig>(measure: Measure<T>) {
this.measures[measure.slug] = measure; this.measures = assign({}, this.measures, {[measure.slug]: measure});
}
@Mutation private DELETE_MEASURE<T extends MeasureConfig>(measure: Measure<T>) {
this.measures = assign({}, omit(this.measures, measure.slug));
} }
} }

View File

@ -54,7 +54,7 @@ export class MeasurementStoreModule extends VuexModule {
const newMeasurements = existing.slice(); const newMeasurements = existing.slice();
const index = findIndex(existing, { id: measurement.id }); const index = findIndex(existing, { id: measurement.id });
if (index > 0) { newMeasurements.push(measurement); } if (index < 0) { newMeasurements.push(measurement); }
else { newMeasurements[index] = measurement; } else { newMeasurements[index] = measurement; }
this.measurements = assign({}, this.measurements, { [measure.id]: newMeasurements }); this.measurements = assign({}, this.measurements, { [measure.id]: newMeasurements });
} }

View File

@ -1,7 +1,9 @@
@import '~@/styles/vars'; @import '~@/styles/vars';
button,
.btn, .btn,
.btn-action { .btn-action,
.btn-icon {
border: 0; border: 0;
border-radius: .25em; border-radius: .25em;
cursor: pointer; cursor: pointer;
@ -13,14 +15,27 @@
a { text-decoration: none; } a { text-decoration: none; }
} }
.btn, .btn-icon { color: $fg-primary; }
.btn-icon {
border-radius: 1em;
padding: .5em;
margin: 0 .5em;
&:hover, &:focus {
background-color: darken($bg-primary, 20%);
}
}
.btn-action { .btn-action {
background-color: $color2; background-color: $color2;
color: $color3; color: $color3;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
&:hover { &:hover, &:focus {
background-color: darken($color2, 5%); background-color: lighten($color2, 20%);
} }
} }

25
web/src/util.ts Normal file
View File

@ -0,0 +1,25 @@
import { Measure, MeasureConfig, Measurement, MeasurementMeta } from '@/models';
import moment from 'moment';
export function byTimestampComparator<T extends MeasurementMeta>(
a: Measurement<T>,
b: Measurement<T>): number {
return a.timestamp.getTime() - b.timestamp.getTime();
}
export function formatTS(
m: Measure<MeasureConfig>,
mm: Measurement<MeasurementMeta>
): string {
return moment(mm.timestamp).format(
m.config.timestampDisplayFormat || 'MMM Do');
}
export function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^\w\s\-]/g, '')
.replace(/\s+/g, '-');
}

View File

@ -0,0 +1,25 @@
<template>
<div v-if="measure">
<div class=header>
<h1>Delete Measure</h1>
<h2>Are you sure you want to delete {{measure.name}}?</h2>
</div>
<form @submit.prevent=deleteMeasure() >
This will delete all measurements associated with this measure. This
cannot be undone.
<div v-if='!waiting' class=form-actions>
<button class=btn-action>Delete</button>
<a class=btn @click="$router.go(-1)">Cancel</a>
</div>
<div v-if='waiting' class=form-waiting>
<div class=wait-spinner>working <fa-icon icon=sync spin /></div>
</div>
</form>
</div>
<div v-else>
<div class=header-action>
<h1>There is no measure named {{$route.params.slug}}.</h1>
</div>
</div>
</template>
<script lang=ts src=./delete-measure.ts></script>

View File

@ -0,0 +1,52 @@
<template>
<div id=edit-measure v-if=measure>
<div class=header>
<h1>Edit Measure</h1>
<h2>{{measure.name}}</h2>
</div>
<form @submit.prevent=updateMeasure() class=edit-measure-form>
<fieldset>
<div>
<label for=measureName>Display Name</label>
<input
:disabled=waiting
type=text
name=measureName
placeholder="what you are measuring"
required
v-model="measure.name" />
</div>
<div>
<label for=measureDescription>Description</label>
<textarea
:disabled=waiting
name=measureDescription
placeholder="optional description"
v-model="measure.description" ></textarea>
</div>
<div>
<label for=measureSlug>Short name (slug)</label>
<input
:disabled=waiting
type=text
name=measureDescription
:placeholder='slugFromName + " (default)"'
:value="measure.slug"
@input="measure.slug = slugify($event.target.value)"/>
</div>
</fieldset>
<MeasureConfigForm
v-model=measure.config
:disabled=waiting
measureExists=false />
<div v-if='!waiting' class=form-actions>
<button class=btn-action>Update</button>
<a class=btn @click="$router.go(-1)">Cancel</a>
</div>
<div v-if='waiting' class=form-waiting>
<div class=wait-spinner>working <fa-icon icon=sync spin /></div>
</div>
</form>
</div>
</template>
<script lang=ts src=./edit-measure.ts></script>

View File

@ -5,7 +5,26 @@
<h1>{{measure.name}}</h1> <h1>{{measure.name}}</h1>
<h2>{{measure.description}}</h2> <h2>{{measure.description}}</h2>
</div> </div>
<router-link :to="'/new/measurement/' + measure.slug" class=btn-action>Add Measurement</router-link> <div class=actions>
<router-link
title="Delete Measure"
:to="'/delete/measure/' + measure.slug"
class=btn-icon >
<fa-icon icon=trash></fa-icon>
</router-link>
<router-link
title="Edit Measure"
:to="'/edit/measure/' + measure.slug"
class=btn-icon>
<fa-icon icon=pencil-alt></fa-icon>
</router-link>
<router-link
title="Add Measurement"
:to="'/new/measurement/' + measure.slug"
class=btn-action>
Add Measurement
</router-link>
</div>
</div> </div>
<MeasureDetails :measure=measure :measurements=measurements /> <MeasureDetails :measure=measure :measurements=measurements />
</div> </div>
@ -16,4 +35,4 @@
</div> </div>
</template> </template>
<script lang="ts" src="./measure.ts"></script> <script lang="ts" src="./measure.ts"></script>
<style lang="scss" src="./measure.scss"></style> <style scoped lang="scss" src="./measure.scss"></style>

View File

@ -12,12 +12,9 @@
<div class=measure-list> <div class=measure-list>
<MeasureSummary <MeasureSummary
v-for="(measure, slug) in measures" v-for="(measure, slug) in measures"
v-show="measure.slug.startsWith(filter)" v-bind:key="measure.id"
v-show="measure.slug.startsWith(filter.toLowerCase())"
:measure=measure /> :measure=measure />
<!--<MeasureSummary
v-for="(measure, slug) in measures"
:key="slug"
:measure=measure />-->
</div> </div>
</div> </div>
</template> </template>

View File

@ -0,0 +1,38 @@
import { Component, Prop, Vue } from 'vue-property-decorator';
import { Measure as MeasureModel, MeasureConfig } from '@/models';
import { measureStore, measurementStore } from '@/store';
import { logService } from '@/services/logging';
const logger = logService.getLogger('/views/delete-measure');
@Component({})
export class DeleteMeasure extends Vue {
private waiting: boolean = false;
private get measure(): MeasureModel<MeasureConfig> | null {
return measureStore.measures[this.$route.params.slug] || null;
}
private async mounted() {
if (!this.measure) {
await measureStore.fetchMeasure(this.$route.params.slug);
}
}
private async deleteMeasure() {
if (this.measure) {
this.waiting = true;
try {
await measureStore.deleteMeasure(this.measure);
this.$router.push({ name: 'measures' });
} catch (e) {
// TODO: show errors
logger.error('Unable to delete measure. \n\t ' + JSON.stringify(this.measure), e.stack);
} finally {
this.waiting = false;
}
}
}
}
export default DeleteMeasure;

View File

@ -0,0 +1,56 @@
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { logService } from '@/services/logging';
import { measureStore, userStore } from '@/store';
import { Measure, MeasureConfig, MeasureType } from '@/models';
import MeasureConfigForm from '@/components/measure-config/MeasureConfigForm.vue';
import { slugify } from '@/util';
const logger = logService.getLogger('/views/edit-measure');
@Component({
components: { MeasureConfigForm }
})
export class EditMeasure extends Vue {
private waiting = false;
private get measure(): Measure<MeasureConfig> | null {
return measureStore.measures[this.$route.params.slug] || null;
}
private get slugFromName() {
if (this.measure) {
return slugify(this.measure.name);
} else {
return null;
}
}
private async updateMeasure() {
if (this.measure) {
if (!this.measure.slug) {
this.measure.slug = slugify(this.measure.name);
}
this.waiting = true;
try {
await measureStore.updateMeasure(this.measure);
this.$router.push({name: 'measure', params: { slug: this.measure.slug }});
} catch (e) {
logger.error('Unable to update measure. \n\t' + JSON.stringify(this.measure), e.stack);
} finally {
this.waiting = false;
}
}
}
private async mounted() {
// good chance we've already fetched this
// TODO: centralize this caching behavior?
if (!this.measure) {
await measureStore.fetchMeasure(this.$route.params.slug);
}
}
}
export default EditMeasure;

View File

@ -1,3 +1 @@
@import '~@/styles/vars'; @import '~@/styles/vars';

View File

@ -1,8 +1,14 @@
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { library } from '@fortawesome/fontawesome-svg-core';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { Measure as MeasureModel, MeasureConfig } from '@/models'; import { Measure as MeasureModel, MeasureConfig } from '@/models';
import { measureStore, measurementStore } from '@/store'; import { measureStore, measurementStore } from '@/store';
import MeasureDetails from '@/components/measure-details/MeasureDetails.vue'; import MeasureDetails from '@/components/measure-details/MeasureDetails.vue';
library.add(faPencilAlt);
library.add(faTrash);
@Component({ @Component({
components: { components: {
MeasureDetails MeasureDetails

View File

@ -5,6 +5,7 @@ import { logService } from '@/services/logging';
import { measureStore, userStore } from '@/store'; import { measureStore, userStore } from '@/store';
import { Measure, MeasureConfig, MeasureType } from '@/models'; import { Measure, MeasureConfig, MeasureType } from '@/models';
import MeasureConfigForm from '@/components/measure-config/MeasureConfigForm.vue'; import MeasureConfigForm from '@/components/measure-config/MeasureConfigForm.vue';
import { slugify } from '@/util';
library.add(faSync); library.add(faSync);
@ -19,7 +20,8 @@ export class NewMeasure extends Vue {
id: '', id: '',
config: { config: {
type: 'simple' as MeasureType, type: 'simple' as MeasureType,
isVisible: true isVisible: true,
timestampDisplayFormat: 'l'
}, },
description: '', description: '',
name: '', name: '',
@ -28,19 +30,12 @@ export class NewMeasure extends Vue {
}; };
private get slugFromName() { private get slugFromName() {
return this.slugify(this.measure.name); return slugify(this.measure.name);
}
private slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^\w\s\-]/g, '')
.replace(/\s+/g, '-');
} }
private async createMeasure() { private async createMeasure() {
if (!this.measure.slug) { if (!this.measure.slug) {
this.measure.slug = this.slugify(this.measure.name); this.measure.slug = slugify(this.measure.name);
} }
this.waiting = true; this.waiting = true;

View File

@ -22,9 +22,7 @@ export class NewMeasurement extends Vue {
measureId: '', measureId: '',
value: 0, value: 0,
timestamp: new Date(), timestamp: new Date(),
extData: { extData: { }
measureType: 'simple' as MeasureType
}
}; };
private async mounted() { private async mounted() {

View File

@ -1,5 +1,7 @@
.user-account { .user-account {
justify-content: flex-start;
section { section {
margin-top: 1rem; margin-top: 2rem;
} }
} }

View File

@ -1,7 +1,14 @@
const merge = require('deepmerge');
const VERSION = {
'process.env': {
PM_VERSION: JSON.stringify(require('./package.json').version)
}
};
module.exports = { module.exports = {
devServer: { devServer: {
proxy: { proxy: {
'/api': { target: 'http://localhost:8081' } '/v0': { target: 'http://localhost:8081' }
}, },
host: 'localhost', host: 'localhost',
disableHostCheck: true disableHostCheck: true
@ -17,5 +24,11 @@ module.exports = {
analyzerMode: 'static', analyzerMode: 'static',
openAnalyzer: false openAnalyzer: false
} }
},
chainWebpack: config => {
config
.plugin('define')
.tap(args => merge(args, [VERSION]))
} }
}; };