Compare commits

...

61 Commits
0.6.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
99 changed files with 4445 additions and 4165 deletions

8
.gitignore vendored
View File

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

View File

@ -1,13 +1,16 @@
VERSION:=$(shell git describe --always)
TARGET_ENV=dev
TARGET_ENV ?= dev
build: dist/personal-measure-api.tar.gz dist/personal-measure-web.tar.gz
dist/personal-measure-api.tar.gz:
-mkdir dist
make -C api personal_measure_api
tar czf dist/personal-measure-api-${VERSION}.tar.gz -C api personal_measure_api
cp dist/personal-measure-api-${VERSION}.tar.gz dist/personal-measure-api.tar.gz
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
@ -15,18 +18,14 @@ dist/personal-measure-web.tar.gz:
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: dist/personal-measure-api.tar.gz
mkdir -p temp-deploy/personal-measure-api-${VERSION}
tar xzf dist/personal-measure-api-${VERSION}.tar.gz -C temp-deploy/personal-measure-api-${VERSION}
-ssh pmapi@pmapi.jdb-labs.com "sudo systemctl stop personal_measure_api.$(TARGET_ENV).service"
scp temp-deploy/personal-measure-api-${VERSION}/personal_measure_api pmapi@pmapi.jdb-labs.com:/home/pmapi/$(TARGET_ENV)/personal_measure_api
ssh pmapi@pmapi.jdb-labs.com "sudo systemctl start personal_measure_api.$(TARGET_ENV).service"
rm -r temp-deploy
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-labs.com/$(TARGET_ENV)/webroot
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

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
# 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 src /pm-api/src
WORKDIR /pm-api
RUN nimble build -y
FROM alpine
#RUN apt-get install -y postgresql-client
EXPOSE 80
RUN apk -v --update add --no-cache \
ca-certificates \
libressl2.7-libssl \
libressl2.7-libcrypto \
libcrypto1.1 \
libssl1.1 \
pcre \
postgresql-client
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"]
# TODO: replace the above with something like:

View File

@ -1,31 +1,123 @@
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)
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
# 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:
# 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
sleep 5
PGPASSWORD=password psql -p 5500 -U postgres -h localhost -c "CREATE DATABASE $(DB_NAME);"
db_migrate up -c database-local.json
start-postgres: postgres.container.id
# Start the existing local Postgres container
docker start $(PGSQL_CONTAINER_ID)
sleep 1
db_migrate up -c database-local.json
stop-postgres: postgres.container.id
# Stop the existing local Postgres container
docker stop $(PGSQL_CONTAINER_ID)
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 container rm $(PGSQL_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}
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",
"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"
}

View File

@ -0,0 +1,4 @@
{
"debug":false,
"pwdCost":11
}

View File

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

View File

@ -1,6 +0,0 @@
{
"debug":false,
"port":80,
"pwdCost":11,
"knownOrigins": [ "https://pm.jdb-labs.com" ]
}

View File

@ -2,7 +2,7 @@
include "src/main/nim/personal_measure_apipkg/version.nim"
version = PM_API_VERSION
version = "0.11.0"
author = "Jonathan Bernard"
description = "JDB\'s Personal Measures API"
license = "MIT"
@ -14,7 +14,8 @@ skipExt = @["nim"]
# Dependencies
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-labs.com/jdb/nim-time-utils.git >= 0.5.0"
requires "https://git.jdb-software.com/jdb/nim-cli-utils.git >= 0.6.3"
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,27 +27,23 @@ proc loadConfig*(args: Table[string, docopt.Value] = initTable[string, docopt.Va
try: json = parseFile(filePath)
except:
json = %DEFAULT_CONFIG
if not existsFile(filePath):
if not fileExists(filePath):
info "created new configuration file \"" & filePath & "\""
filePath.writeFile($json)
else:
warn "Cannot read configuration file \"" & filePath & "\":\n\t" &
getCurrentExceptionMsg()
let knownOriginsArray =
if json.hasKey("knownOrigins"): json["knownOrigins"]
else: newJArray()
let cfg = CombinedConfig(docopt: args, json: json)
result = PMApiConfig(
authSecret: cfg.getVal("auth-secret"),
dbConnString: cfg.getVal("db-conn-string"),
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"))),
knownOrigins: toSeq(knownOriginsArray).mapIt(it.getStr))
knownOrigins: cfg.getVal("known-origins")[1..^2].split(',').mapIt(it[1..^2]))
proc initContext(args: Table[string, docopt.Value]): PMApiContext =
var cfg: PMApiConfig
@ -114,6 +110,6 @@ Options:
if args["serve"]: start(ctx)
except:
fatal "pit: " & getCurrentExceptionMsg()
fatal "personal_measure_api: " & getCurrentExceptionMsg()
#raise getCurrentException()
quit(QuitFailure)

View File

@ -1,7 +1,9 @@
import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils,
strutils, times, uuids
times, uuids
from httpcore import HttpMethod
from unicode import capitalize
import timeutils except `<`
import strutils except capitalize
import timeutils
import ./db, ./configuration, ./models, ./service, ./version
@ -20,7 +22,7 @@ proc newSession*(user: User): Session =
template halt(code: HttpCode,
headers: RawHeaders,
content: string): typed =
content: string) =
## Immediately replies with the specified request. This means any further
## code will not be executed after calling this template in the current
## route.
@ -43,7 +45,8 @@ template jsonResp(code: HttpCode, body: string = "", headersToSend: RawHeaders =
@{
"Access-Control-Allow-Origin": reqOrigin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": $(request.reqMethod)
"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: @{:}
@ -56,6 +59,29 @@ template jsonResp(code: HttpCode, body: string = "", headersToSend: RawHeaders =
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 = @{:} ) =
@ -68,11 +94,6 @@ template statusResp(code: HttpCode, details: string = "", headersToSend: RawHead
}),
headersToSend)
template execptionResp(ex: ref Exception, details: string = ""): void =
when not defined(release): debug ex.getStackTrace()
error details & ":\n" & ex.msg
statusResp(Http500)
# internal JSON parsing utils
proc getIfExists(n: JsonNode, key: string): JsonNode =
## convenience method to get a key from a JObject or return null
@ -100,7 +121,7 @@ proc fromJWT*(ctx: PMApiContext, strTok: string): Session =
## Validate a given JWT and extract the session data.
let jwt = toJWT(strTok)
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()
# Find the user record (if authenticated)
@ -211,13 +232,17 @@ proc start*(ctx: PMApiContext): void =
settings:
port = Port(ctx.cfg.port)
appName = "/api"
appName = "/v0"
routes:
options "/version": optionsResp(@[HttpGet])
get "/version":
jsonResp($(%("personal_measure_api v" & PM_API_VERSION)))
options "/auth-token": optionsResp(@[HttpPost])
post "/auth-token":
try:
@ -229,6 +254,8 @@ proc start*(ctx: PMApiContext): void =
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except: statusResp(Http401, getCurrentExceptionMsg())
options "/change-pwd": optionsResp(@[HttpPost])
post "/change-pwd":
checkAuth()
@ -250,6 +277,8 @@ proc start*(ctx: PMApiContext): void =
error "internal error changing password: " & getCurrentExceptionMsg()
statusResp(Http500)
options "/change-pwd/@userId": optionsResp(@[HttpPost])
post "/change-pwd/@userId":
checkAuth(true)
@ -271,6 +300,8 @@ proc start*(ctx: PMApiContext): void =
error "internal error changing password: " & getCurrentExceptionMsg()
statusResp(Http500)
options "/user": optionsResp(@[HttpGet, HttpPut])
get "/user":
checkAuth()
@ -295,6 +326,8 @@ proc start*(ctx: PMApiContext): void =
error "Could not update user information:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/users": optionsResp(@[HttpGet, HttpPost])
get "/users":
checkAuth(true)
@ -323,6 +356,8 @@ proc start*(ctx: PMApiContext): void =
error "Could not create new user:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/users/@userId": optionsResp(@[HttpGet, HttpDelete])
get "/users/@userId":
checkAuth(true)
@ -343,6 +378,8 @@ proc start*(ctx: PMApiContext): void =
except: statusResp(Http500, getCurrentExceptionMsg())
options "/api-tokens": optionsResp(@[HttpGet, HttpPost])
get "/api-tokens":
checkAuth()
@ -377,6 +414,8 @@ proc start*(ctx: PMApiContext): void =
debug getCurrentExceptionMsg()
statusResp(Http500)
options "/api-tokens/@tokenId": optionsResp(@[HttpGet, HttpDelete])
get "/api-tokens/@tokenId":
checkAuth()
@ -395,6 +434,10 @@ proc start*(ctx: PMApiContext): void =
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: statusResp(Http500)
# Measure
options "/measures": optionsResp(@[HttpGet, HttpPost])
get "/measures":
checkAuth()
@ -439,6 +482,8 @@ proc start*(ctx: PMApiContext): void =
error "unable to create new measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/measures/@slug": optionsResp(@[HttpGet, HttpPost, HttpDelete])
get "/measures/@slug":
checkAuth()
@ -448,6 +493,37 @@ proc start*(ctx: PMApiContext): void =
error "unable to look up a measure by id:\n\t" & getCurrentExceptionMsg()
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":
checkAuth()
@ -460,7 +536,11 @@ proc start*(ctx: PMApiContext): void =
error "unable to delete a measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
get "/measure/@slug":
# Measurements
options "/measurements/@slug": optionsResp(@[HttpGet, HttpPost])
get "/measurements/@slug":
checkAuth()
try:
@ -471,7 +551,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to list measurements:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
post "/measure/@slug":
post "/measurements/@slug":
checkAuth()
try:
@ -480,7 +560,7 @@ proc start*(ctx: PMApiContext): void =
let newMeasurement = Measurement(
measureId: measure.id,
value: jsonBody.getOrFail("value").getInt,
value: jsonBody.getOrFail("value").getFloat,
timestamp:
if jsonBody.hasKey("timestamp"): jsonBody["timestamp"].getStr.parseIso8601.utc
else: getTime().utc,
@ -497,7 +577,9 @@ proc start*(ctx: PMApiContext): void =
error "unable to add measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
get "/measure/@slug/@id":
options "/measurements/@slug/@id": optionsResp(@[HttpGet, HttpPut, HttpDelete])
get "/measurements/@slug/@id":
checkAuth()
try:
@ -512,14 +594,14 @@ proc start*(ctx: PMApiContext): void =
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
put "/measure/@slug/@id":
put "/measurements/@slug/@id":
checkAuth()
try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
var measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id"))
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("extData"): measurement.extData = jsonBody["extData"]
jsonResp($(%ctx.db.updateMeasurement(measurement)))
@ -532,7 +614,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
delete "/measure/@slug/@id":
delete "/measurements/@slug/@id":
checkAuth()
try:
@ -549,6 +631,8 @@ proc start*(ctx: PMApiContext): void =
error "unable to delete measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/log": optionsResp(@[HttpPost])
post "/log":
checkAuth()
@ -566,6 +650,8 @@ proc start*(ctx: PMApiContext): void =
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except: statusResp(Http500, getCurrentExceptionMsg())
options "/log/batch": optionsResp(@[HttpPost])
post "/log/batch":
checkAuth()

View File

@ -1,31 +1,35 @@
import db_postgres, macros, options, postgres, sequtils, strutils,
times, timeutils, unicode, uuids
import db_postgres, fiber_orm, sequtils, uuids
import ./models
import ./db_common
export db_common.NotFoundError
export fiber_orm.NotFoundError
type
PMApiDb* = ref object
conn: DbConn
proc connect*(connString: string): PMApiDb =
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(ApiToken, @["hashedToken"])
generateLookup(PMApiDb, ApiToken, @["userId"])
generateLookup(PMApiDb, ApiToken, @["hashedToken"])
generateLookup(Measure, @["userId"])
generateLookup(Measure, @["userId", "id"])
generateLookup(Measure, @["userId", "slug"])
generateLookup(PMApiDb, Measure, @["userId"])
generateLookup(PMApiDb, Measure, @["userId", "id"])
generateLookup(PMApiDb, Measure, @["userId", "slug"])
generateLookup(Measurement, @["measureId"])
generateLookup(Measurement, @["measureId", "id"])
generateLookup(PMApiDb, Measurement, @["measureId"])
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,287 +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'.'fzz",
"yyyy-MM-dd HH:mm:ss'.'ffzz",
"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
id*: UUID
measureId*: UUID
value*: int
value*: float
timestamp*: DateTime
extData*: JsonNode

View File

@ -1 +1 @@
const PM_API_VERSION* = "0.4.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

@ -1,24 +1,37 @@
#!/bin/bash
api_base_url="${PM_API_BASE_URL:-http://localhost:8081}"
api_base_url="${PM_API_BASE_URL:-http://localhost:8100/v0}"
if [ $# -eq 1 ]; then
url="$1"
method="GET"
data=""
elif [ $# -eq 2 ]; then
method="$1"
url="$2"
data=""
else
if [ $1 == "auth-token" ]; then
curl -s -X POST \
-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
method="$1"
url="$2"
data="$3"
fi
if [[ ! $url = /* ]]; then url="/$url"; fi
curl -s -X "$method" \
-H "Content-Type: application/json" \
-H "Authorization: $(cat credential)" \
-H "Origin: https://curl.localhost" \
"${api_base_url}/api/$url" \
"${api_base_url}$url" \
-d "$data" \
-v
| 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.

View File

@ -1,10 +0,0 @@
<RoutingRules>
<RoutingRule>
<Condition>
<KeyPrefixEquals>api</KeyPrefixEquals>
</Condition>
<Redirect>
<HostName>https://pmapi.jdbernard.com</HostName>
</Redirect>
</RoutingRule>
</RoutingRules>

View File

@ -46,9 +46,6 @@ user to manage these without a password.
pmapi ALL=NOPASSWD: /bin/systemctl stop personal_measure_api.dev.service
pmapi ALL=NOPASSWD: /bin/systemctl start personal_measure_api.dev.service
two systemd
service definitions, one for
### Database
razgriz-db.jdb-labs.com RDS instance maintains databases for each environment:
@ -60,17 +57,9 @@ razgriz-db.jdb-labs.com RDS instance maintains databases for each environment:
CloudFront manages the routing of all of the external facing URLs.
https://pm.jdb-labs.com (CloudFront)
├── /api/<path>
│ └── https://pmapi.jdb-labs.com/api/
│ ├── nginx:80 --> nim/jester:8280
│ └── razgriz-db: database personal_measure
└── s3://pm.jdb-labs.com/prod/webroot (static HTML)
https://pm-dev.jdb-labs.com (CloudFront)
├── /api/<path>
│ └── https://pmapi-dev.jdb-labs.com/api/
│ ├── nginx:80 --> nim/jester:8281
│ └── razgriz-db: database personal_measure_dev
└── s3://pm.jdb-labs.com/dev/webroot (static HTML)

View File

@ -5,15 +5,7 @@ variable "aws_region" {
default = "us-west-2" # Oregon
}
variable "deploy_bucket_name" {
variable "app_root_url" {
description = "Name of the S3 bucket to store deployed artifacts, logs, etc."
default = "pm.jdb-labs.com"
default = "pm.jdb-software.com"
}
#### Provider Configuration
provider "aws" {
region = var.aws_region
}

View File

@ -6,18 +6,18 @@ data "aws_iam_policy_document" "bucket_access_policy" {
principals {
type = "AWS"
identifiers = [ "${aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn}" ]
identifiers = [ aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn ]
}
}
statement {
actions = [ "s3:ListBucket" ]
effect = "Allow"
resources = [ "${var.artifact_bucket.arn}" ]
resources = [ var.artifact_bucket.arn ]
principals {
type = "AWS"
identifiers = [ "${aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn}" ]
identifiers = [ aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn ]
}
}
}
@ -26,22 +26,18 @@ output "oai_access_policy" {
value = data.aws_iam_policy_document.bucket_access_policy
}
locals {
env_domain_name = "pm${var.environment == "prod" ? "" : "-${var.environment}"}.jdb-labs.com"
}
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}"
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}"
origin_access_identity = aws_cloudfront_origin_access_identity.origin_access_identity.cloudfront_access_identity_path
}
}
@ -52,11 +48,11 @@ resource "aws_cloudfront_distribution" "s3_distribution" {
logging_config {
include_cookies = false
bucket = "${var.artifact_bucket.bucket_domain_name}"
bucket = var.artifact_bucket.bucket_domain_name
prefix = "${var.environment}/logs/cloudfront"
}
aliases = ["${local.env_domain_name}"]
aliases = [local.app_domain_name]
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
@ -92,11 +88,11 @@ resource "aws_cloudfront_distribution" "s3_distribution" {
}
}
tags = {
Environment = "${var.environment}"
Environment = local.environment_name
}
viewer_certificate {
acm_certificate_arn = "${var.cloudfront_ssl_certificate_arn}"
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

@ -8,6 +8,27 @@ variable "artifact_bucket" {
description = "The aws_s3_bucket object representing the artifact bucket where deployed artifacts, logs, etc. live."
}
variable "cloudfront_ssl_certificate_arn" {
description = "ARN of the managed SSL certificate to use for this environment."
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

@ -1,22 +1,26 @@
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "personal_measure" {
bucket = "${var.deploy_bucket_name}"
bucket = var.app_root_url
acl = "log-delivery-write"
}
module "dev_env" {
source = "./deployed_env"
environment = "dev"
environment = "dev"
artifact_bucket = aws_s3_bucket.personal_measure
cloudfront_ssl_certificate_arn = "arn:aws:acm:us-east-1:063932952339:certificate/48fe3ce0-4700-4eaa-b433-bb634f47934c"
ecr_repo = aws_ecr_repository.personal_measure_api
}
module "prod_env" {
source = "./deployed_env"
environment = "prod"
environment = "prod"
artifact_bucket = aws_s3_bucket.personal_measure
cloudfront_ssl_certificate_arn = "arn:aws:acm:us-east-1:063932952339:certificate/48fe3ce0-4700-4eaa-b433-bb634f47934c"
ecr_repo = aws_ecr_repository.personal_measure_api
}
data "aws_iam_policy_document" "cloudfront_access_policy" {
@ -25,6 +29,6 @@ data "aws_iam_policy_document" "cloudfront_access_policy" {
}
resource "aws_s3_bucket_policy" "personal_measure" {
bucket = "${aws_s3_bucket.personal_measure.id}"
policy = "${data.aws_iam_policy_document.cloudfront_access_policy.json}"
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"
}
}

View File

@ -1,547 +0,0 @@
{
"version": 4,
"terraform_version": "0.12.9",
"serial": 13,
"lineage": "07ea4679-dcfc-ec03-69c0-9f3b3df53386",
"outputs": {},
"resources": [
{
"module": "module.prod_env",
"mode": "data",
"type": "aws_iam_policy_document",
"name": "bucket_access_policy",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "4164925389",
"json": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/prod/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EV7VQF8SH3HMM\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EV7VQF8SH3HMM\"\n }\n }\n ]\n}",
"override_json": null,
"policy_id": null,
"source_json": null,
"statement": [
{
"actions": [
"s3:GetObject"
],
"condition": [],
"effect": "Allow",
"not_actions": [],
"not_principals": [],
"not_resources": [],
"principals": [
{
"identifiers": [
"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EV7VQF8SH3HMM"
],
"type": "AWS"
}
],
"resources": [
"arn:aws:s3:::pm.jdb-labs.com/prod/webroot/*"
],
"sid": ""
},
{
"actions": [
"s3:ListBucket"
],
"condition": [],
"effect": "Allow",
"not_actions": [],
"not_principals": [],
"not_resources": [],
"principals": [
{
"identifiers": [
"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EV7VQF8SH3HMM"
],
"type": "AWS"
}
],
"resources": [
"arn:aws:s3:::pm.jdb-labs.com"
],
"sid": ""
}
],
"version": "2012-10-17"
},
"depends_on": [
"aws_cloudfront_origin_access_identity.origin_access_identity"
]
}
]
},
{
"module": "module.dev_env",
"mode": "data",
"type": "aws_iam_policy_document",
"name": "bucket_access_policy",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "672870168",
"json": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/dev/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ENADNQSO0I1JY\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ENADNQSO0I1JY\"\n }\n }\n ]\n}",
"override_json": null,
"policy_id": null,
"source_json": null,
"statement": [
{
"actions": [
"s3:GetObject"
],
"condition": [],
"effect": "Allow",
"not_actions": [],
"not_principals": [],
"not_resources": [],
"principals": [
{
"identifiers": [
"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ENADNQSO0I1JY"
],
"type": "AWS"
}
],
"resources": [
"arn:aws:s3:::pm.jdb-labs.com/dev/webroot/*"
],
"sid": ""
},
{
"actions": [
"s3:ListBucket"
],
"condition": [],
"effect": "Allow",
"not_actions": [],
"not_principals": [],
"not_resources": [],
"principals": [
{
"identifiers": [
"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ENADNQSO0I1JY"
],
"type": "AWS"
}
],
"resources": [
"arn:aws:s3:::pm.jdb-labs.com"
],
"sid": ""
}
],
"version": "2012-10-17"
},
"depends_on": [
"aws_cloudfront_origin_access_identity.origin_access_identity"
]
}
]
},
{
"mode": "data",
"type": "aws_iam_policy_document",
"name": "cloudfront_access_policy",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "1534115699",
"json": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/dev/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ENADNQSO0I1JY\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ENADNQSO0I1JY\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/prod/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EV7VQF8SH3HMM\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EV7VQF8SH3HMM\"\n }\n }\n ]\n}",
"override_json": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/prod/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EV7VQF8SH3HMM\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EV7VQF8SH3HMM\"\n }\n }\n ]\n}",
"policy_id": null,
"source_json": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/dev/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ENADNQSO0I1JY\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ENADNQSO0I1JY\"\n }\n }\n ]\n}",
"statement": null,
"version": "2012-10-17"
},
"depends_on": [
"module.dev_env",
"module.prod_env"
]
}
]
},
{
"module": "module.prod_env",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"name": "s3_distribution",
"provider": "provider.aws",
"instances": [
{
"schema_version": 1,
"attributes": {
"active_trusted_signers": {
"enabled": "false",
"items.#": "0"
},
"aliases": [
"pm.jdb-labs.com"
],
"arn": "arn:aws:cloudfront::063932952339:distribution/E331OLEUZMJYX2",
"cache_behavior": [],
"caller_reference": "terraform-20190924171430991900000002",
"comment": "Personal Measure prod distribution.",
"custom_error_response": [
{
"error_caching_min_ttl": null,
"error_code": 404,
"response_code": 200,
"response_page_path": "/index.html"
}
],
"default_cache_behavior": [
{
"allowed_methods": [
"GET",
"HEAD",
"OPTIONS"
],
"cached_methods": [
"GET",
"HEAD",
"OPTIONS"
],
"compress": true,
"default_ttl": 31536000,
"field_level_encryption_id": "",
"forwarded_values": [
{
"cookies": [
{
"forward": "none",
"whitelisted_names": null
}
],
"headers": null,
"query_string": false,
"query_string_cache_keys": null
}
],
"lambda_function_association": [],
"max_ttl": 31536000,
"min_ttl": 0,
"smooth_streaming": false,
"target_origin_id": "S3-PersonalMeasure-prod",
"trusted_signers": null,
"viewer_protocol_policy": "redirect-to-https"
}
],
"default_root_object": "/index.html",
"domain_name": "d1pydbw1mwi6dq.cloudfront.net",
"enabled": true,
"etag": "E39Y9O0I859AQB",
"hosted_zone_id": "Z2FDTNDATAQYW2",
"http_version": "http2",
"id": "E331OLEUZMJYX2",
"in_progress_validation_batches": 0,
"is_ipv6_enabled": true,
"last_modified_time": "2019-09-24 17:14:34.861 +0000 UTC",
"logging_config": [
{
"bucket": "pm.jdb-labs.com.s3.amazonaws.com",
"include_cookies": false,
"prefix": "prod/logs/cloudfront"
}
],
"ordered_cache_behavior": [],
"origin": [
{
"custom_header": [],
"custom_origin_config": [],
"domain_name": "pm.jdb-labs.com.s3.us-west-2.amazonaws.com",
"origin_id": "S3-PersonalMeasure-prod",
"origin_path": "/prod/webroot",
"s3_origin_config": [
{
"origin_access_identity": "origin-access-identity/cloudfront/EV7VQF8SH3HMM"
}
]
}
],
"origin_group": [],
"price_class": "PriceClass_100",
"restrictions": [
{
"geo_restriction": [
{
"locations": null,
"restriction_type": "none"
}
]
}
],
"retain_on_delete": false,
"status": "Deployed",
"tags": {
"Environment": "prod"
},
"viewer_certificate": [
{
"acm_certificate_arn": "arn:aws:acm:us-east-1:063932952339:certificate/48fe3ce0-4700-4eaa-b433-bb634f47934c",
"cloudfront_default_certificate": false,
"iam_certificate_id": "",
"minimum_protocol_version": "TLSv1",
"ssl_support_method": "sni-only"
}
],
"wait_for_deployment": true,
"web_acl_id": ""
},
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
"depends_on": [
"aws_cloudfront_origin_access_identity.origin_access_identity"
]
}
]
},
{
"module": "module.dev_env",
"mode": "managed",
"type": "aws_cloudfront_distribution",
"name": "s3_distribution",
"provider": "provider.aws",
"instances": [
{
"schema_version": 1,
"attributes": {
"active_trusted_signers": {
"enabled": "false",
"items.#": "0"
},
"aliases": [
"pm-dev.jdb-labs.com"
],
"arn": "arn:aws:cloudfront::063932952339:distribution/EYDKNEMGBYXK6",
"cache_behavior": [],
"caller_reference": "terraform-20190924171430991900000001",
"comment": "Personal Measure dev distribution.",
"custom_error_response": [
{
"error_caching_min_ttl": null,
"error_code": 404,
"response_code": 200,
"response_page_path": "/index.html"
}
],
"default_cache_behavior": [
{
"allowed_methods": [
"GET",
"HEAD",
"OPTIONS"
],
"cached_methods": [
"GET",
"HEAD",
"OPTIONS"
],
"compress": true,
"default_ttl": 31536000,
"field_level_encryption_id": "",
"forwarded_values": [
{
"cookies": [
{
"forward": "none",
"whitelisted_names": null
}
],
"headers": null,
"query_string": false,
"query_string_cache_keys": null
}
],
"lambda_function_association": [],
"max_ttl": 31536000,
"min_ttl": 0,
"smooth_streaming": false,
"target_origin_id": "S3-PersonalMeasure-dev",
"trusted_signers": null,
"viewer_protocol_policy": "redirect-to-https"
}
],
"default_root_object": "/index.html",
"domain_name": "d2gk6d79ot5fv3.cloudfront.net",
"enabled": true,
"etag": "E1DN3CB5IQVST8",
"hosted_zone_id": "Z2FDTNDATAQYW2",
"http_version": "http2",
"id": "EYDKNEMGBYXK6",
"in_progress_validation_batches": 0,
"is_ipv6_enabled": true,
"last_modified_time": "2019-09-24 17:14:32.614 +0000 UTC",
"logging_config": [
{
"bucket": "pm.jdb-labs.com.s3.amazonaws.com",
"include_cookies": false,
"prefix": "dev/logs/cloudfront"
}
],
"ordered_cache_behavior": [],
"origin": [
{
"custom_header": [],
"custom_origin_config": [],
"domain_name": "pm.jdb-labs.com.s3.us-west-2.amazonaws.com",
"origin_id": "S3-PersonalMeasure-dev",
"origin_path": "/dev/webroot",
"s3_origin_config": [
{
"origin_access_identity": "origin-access-identity/cloudfront/ENADNQSO0I1JY"
}
]
}
],
"origin_group": [],
"price_class": "PriceClass_100",
"restrictions": [
{
"geo_restriction": [
{
"locations": null,
"restriction_type": "none"
}
]
}
],
"retain_on_delete": false,
"status": "Deployed",
"tags": {
"Environment": "dev"
},
"viewer_certificate": [
{
"acm_certificate_arn": "arn:aws:acm:us-east-1:063932952339:certificate/48fe3ce0-4700-4eaa-b433-bb634f47934c",
"cloudfront_default_certificate": false,
"iam_certificate_id": "",
"minimum_protocol_version": "TLSv1",
"ssl_support_method": "sni-only"
}
],
"wait_for_deployment": true,
"web_acl_id": ""
},
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
"depends_on": [
"aws_cloudfront_origin_access_identity.origin_access_identity"
]
}
]
},
{
"module": "module.prod_env",
"mode": "managed",
"type": "aws_cloudfront_origin_access_identity",
"name": "origin_access_identity",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"caller_reference": "terraform-20190924170615555500000002",
"cloudfront_access_identity_path": "origin-access-identity/cloudfront/EV7VQF8SH3HMM",
"comment": "OAI for Personal Measure {$var.environment} environment.",
"etag": "E1XJOGSBHHRD9K",
"iam_arn": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EV7VQF8SH3HMM",
"id": "EV7VQF8SH3HMM",
"s3_canonical_user_id": "3a882d18f05e2fa5a3cabc208bcb8c0e2143166b56c0b8442f5b8b405c203859a3f525afcabc2e52dd1c9799d883a166"
},
"private": "bnVsbA=="
}
]
},
{
"module": "module.dev_env",
"mode": "managed",
"type": "aws_cloudfront_origin_access_identity",
"name": "origin_access_identity",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"caller_reference": "terraform-20190924170615555100000001",
"cloudfront_access_identity_path": "origin-access-identity/cloudfront/ENADNQSO0I1JY",
"comment": "OAI for Personal Measure {$var.environment} environment.",
"etag": "E1K0T63S2F5CYR",
"iam_arn": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ENADNQSO0I1JY",
"id": "ENADNQSO0I1JY",
"s3_canonical_user_id": "6e965a9a0e9034badac65e1ac223e048b6d1b934d146abd32c49634489959a5ee1252e34fb643cd222dde425f2abfcd4"
},
"private": "bnVsbA=="
}
]
},
{
"mode": "managed",
"type": "aws_s3_bucket",
"name": "personal_measure",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"acceleration_status": "",
"acl": "log-delivery-write",
"arn": "arn:aws:s3:::pm.jdb-labs.com",
"bucket": "pm.jdb-labs.com",
"bucket_domain_name": "pm.jdb-labs.com.s3.amazonaws.com",
"bucket_prefix": null,
"bucket_regional_domain_name": "pm.jdb-labs.com.s3.us-west-2.amazonaws.com",
"cors_rule": [],
"force_destroy": false,
"hosted_zone_id": "Z3BJ6K6RIION7M",
"id": "pm.jdb-labs.com",
"lifecycle_rule": [],
"logging": [],
"object_lock_configuration": [],
"policy": null,
"region": "us-west-2",
"replication_configuration": [],
"request_payer": "BucketOwner",
"server_side_encryption_configuration": [],
"tags": {},
"versioning": [
{
"enabled": false,
"mfa_delete": false
}
],
"website": [],
"website_domain": null,
"website_endpoint": null
},
"private": "bnVsbA=="
}
]
},
{
"mode": "managed",
"type": "aws_s3_bucket_policy",
"name": "personal_measure",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"bucket": "pm.jdb-labs.com",
"id": "pm.jdb-labs.com",
"policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/dev/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ENADNQSO0I1JY\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ENADNQSO0I1JY\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/prod/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EV7VQF8SH3HMM\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EV7VQF8SH3HMM\"\n }\n }\n ]\n}"
},
"private": "bnVsbA==",
"depends_on": [
"aws_s3_bucket.personal_measure",
"data.aws_iam_policy_document.cloudfront_access_policy"
]
}
]
}
]
}

View File

@ -1,279 +0,0 @@
{
"version": 4,
"terraform_version": "0.12.9",
"serial": 9,
"lineage": "07ea4679-dcfc-ec03-69c0-9f3b3df53386",
"outputs": {},
"resources": [
{
"module": "module.prod_env",
"mode": "data",
"type": "aws_iam_policy_document",
"name": "bucket_access_policy",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "1727217411",
"json": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/prod/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_EV7VQF8SH3HMM\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_EV7VQF8SH3HMM\"\n }\n }\n ]\n}",
"override_json": null,
"policy_id": null,
"source_json": null,
"statement": [
{
"actions": [
"s3:GetObject"
],
"condition": [],
"effect": "Allow",
"not_actions": [],
"not_principals": [],
"not_resources": [],
"principals": [
{
"identifiers": [
"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_EV7VQF8SH3HMM"
],
"type": "AWS"
}
],
"resources": [
"arn:aws:s3:::pm.jdb-labs.com/prod/webroot/*"
],
"sid": ""
},
{
"actions": [
"s3:ListBucket"
],
"condition": [],
"effect": "Allow",
"not_actions": [],
"not_principals": [],
"not_resources": [],
"principals": [
{
"identifiers": [
"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_EV7VQF8SH3HMM"
],
"type": "AWS"
}
],
"resources": [
"arn:aws:s3:::pm.jdb-labs.com"
],
"sid": ""
}
],
"version": "2012-10-17"
},
"depends_on": [
"aws_cloudfront_origin_access_identity.origin_access_identity"
]
}
]
},
{
"module": "module.dev_env",
"mode": "data",
"type": "aws_iam_policy_document",
"name": "bucket_access_policy",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "3067586518",
"json": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/dev/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_ENADNQSO0I1JY\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_ENADNQSO0I1JY\"\n }\n }\n ]\n}",
"override_json": null,
"policy_id": null,
"source_json": null,
"statement": [
{
"actions": [
"s3:GetObject"
],
"condition": [],
"effect": "Allow",
"not_actions": [],
"not_principals": [],
"not_resources": [],
"principals": [
{
"identifiers": [
"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_ENADNQSO0I1JY"
],
"type": "AWS"
}
],
"resources": [
"arn:aws:s3:::pm.jdb-labs.com/dev/webroot/*"
],
"sid": ""
},
{
"actions": [
"s3:ListBucket"
],
"condition": [],
"effect": "Allow",
"not_actions": [],
"not_principals": [],
"not_resources": [],
"principals": [
{
"identifiers": [
"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_ENADNQSO0I1JY"
],
"type": "AWS"
}
],
"resources": [
"arn:aws:s3:::pm.jdb-labs.com"
],
"sid": ""
}
],
"version": "2012-10-17"
},
"depends_on": [
"aws_cloudfront_origin_access_identity.origin_access_identity"
]
}
]
},
{
"mode": "data",
"type": "aws_iam_policy_document",
"name": "cloudfront_access_policy",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "754132408",
"json": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/dev/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_ENADNQSO0I1JY\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_ENADNQSO0I1JY\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/prod/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_EV7VQF8SH3HMM\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_EV7VQF8SH3HMM\"\n }\n }\n ]\n}",
"override_json": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/prod/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_EV7VQF8SH3HMM\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_EV7VQF8SH3HMM\"\n }\n }\n ]\n}",
"policy_id": null,
"source_json": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/dev/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_ENADNQSO0I1JY\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_ENADNQSO0I1JY\"\n }\n }\n ]\n}",
"statement": null,
"version": "2012-10-17"
},
"depends_on": [
"module.dev_env",
"module.prod_env"
]
}
]
},
{
"module": "module.prod_env",
"mode": "managed",
"type": "aws_cloudfront_origin_access_identity",
"name": "origin_access_identity",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"caller_reference": "terraform-20190924170615555500000002",
"cloudfront_access_identity_path": "origin-access-identity/cloudfront/EV7VQF8SH3HMM",
"comment": "OAI for Personal Measure {$var.environment} environment.",
"etag": "E1XJOGSBHHRD9K",
"iam_arn": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EV7VQF8SH3HMM",
"id": "EV7VQF8SH3HMM",
"s3_canonical_user_id": "3a882d18f05e2fa5a3cabc208bcb8c0e2143166b56c0b8442f5b8b405c203859a3f525afcabc2e52dd1c9799d883a166"
},
"private": "bnVsbA=="
}
]
},
{
"module": "module.dev_env",
"mode": "managed",
"type": "aws_cloudfront_origin_access_identity",
"name": "origin_access_identity",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"caller_reference": "terraform-20190924170615555100000001",
"cloudfront_access_identity_path": "origin-access-identity/cloudfront/ENADNQSO0I1JY",
"comment": "OAI for Personal Measure {$var.environment} environment.",
"etag": "E1K0T63S2F5CYR",
"iam_arn": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ENADNQSO0I1JY",
"id": "ENADNQSO0I1JY",
"s3_canonical_user_id": "6e965a9a0e9034badac65e1ac223e048b6d1b934d146abd32c49634489959a5ee1252e34fb643cd222dde425f2abfcd4"
},
"private": "bnVsbA=="
}
]
},
{
"mode": "managed",
"type": "aws_s3_bucket",
"name": "personal_measure",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"acceleration_status": "",
"acl": "log-delivery-write",
"arn": "arn:aws:s3:::pm.jdb-labs.com",
"bucket": "pm.jdb-labs.com",
"bucket_domain_name": "pm.jdb-labs.com.s3.amazonaws.com",
"bucket_prefix": null,
"bucket_regional_domain_name": "pm.jdb-labs.com.s3.us-west-2.amazonaws.com",
"cors_rule": [],
"force_destroy": false,
"hosted_zone_id": "Z3BJ6K6RIION7M",
"id": "pm.jdb-labs.com",
"lifecycle_rule": [],
"logging": [],
"object_lock_configuration": [],
"policy": null,
"region": "us-west-2",
"replication_configuration": [],
"request_payer": "BucketOwner",
"server_side_encryption_configuration": [],
"tags": {},
"versioning": [
{
"enabled": false,
"mfa_delete": false
}
],
"website": [],
"website_domain": null,
"website_endpoint": null
},
"private": "bnVsbA=="
}
]
},
{
"mode": "managed",
"type": "aws_s3_bucket_policy",
"name": "personal_measure",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
"bucket": "pm.jdb-labs.com",
"id": "pm.jdb-labs.com",
"policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/dev/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_ENADNQSO0I1JY\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_ENADNQSO0I1JY\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:GetObject\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com/prod/webroot/*\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_EV7VQF8SH3HMM\"\n }\n },\n {\n \"Sid\": \"\",\n \"Effect\": \"Allow\",\n \"Action\": \"s3:ListBucket\",\n \"Resource\": \"arn:aws:s3:::pm.jdb-labs.com\",\n \"Principal\": {\n \"AWS\": \"arn:aws:iam::cloudfront:user/CloudFront_Origin_Access_Identity_EV7VQF8SH3HMM\"\n }\n }\n ]\n}"
},
"private": "bnVsbA==",
"depends_on": [
"aws_s3_bucket.personal_measure",
"data.aws_iam_policy_document.cloudfront_access_policy"
]
}
]
}
]
}

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

View File

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

View File

@ -2,19 +2,40 @@
<fieldset>
<div>
<label for=measureType>Type</label>
<span v-if=measureExists>{{value.type}}</span>
<select
:disabled=disabled
name=measureType
v-if="!measureExists"
v-model=value.type>
<option value=simple>Simple</option>
<option value=list>List</option>
<option value=text>Text</option>
</select>
</div>
<div>
<label for=measureIsVisible>Show by default.</label>
<input type=checkbox v-model=value.isVisible :disabled=disabled />
</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>
</template>
<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 { logService } from '@/services/logging';
import { Measure, MeasureConfig } from '@/models';
import TextMeasureConfigForm from './TextMeasureConfigForm.vue';
import moment from 'moment';
@Component({})
@Component({
components: {
TextMeasureConfigForm
}
})
export class MeasureConfigForm extends Vue {
@Prop({}) public value!: MeasureConfig;
@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 })
@Emit('input')
private onConfigChanged(newVal: MeasureConfig, oldVal: MeasureConfig) {
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;

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>
<SimpleDetails v-if="measure.config.type === 'simple'"
:measure=measure :measurements=measurements />
<TextDetails v-if="measure.config.type === 'text'"
:measure=measure :measurements=measurements />
</div>
</template>
<script lang="ts" src="./measure-details.ts"></script>

View File

@ -15,7 +15,6 @@
</tr>
</tbody>
</v-table>
<SimpleEntry :measure=measure v-model=
</div>
</template>
<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 { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
import SimpleDetails from './SimpleDetails.vue';
import TextDetails from './TextDetails.vue';
@Component({
components: { SimpleDetails }
components: {
SimpleDetails,
TextDetails
}
})
export class MeasureDetails extends Vue {
@Prop() private measure!: Measure<MeasureConfig>;

View File

@ -1,9 +1,9 @@
import { Component, Prop, Vue } from 'vue-property-decorator';
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
import moment from 'moment';
import assign from 'lodash.assign';
import { library } from '@fortawesome/fontawesome-svg-core';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { byTimestampComparator, formatTS } from '@/util';
library.add(faPencilAlt);
@ -12,28 +12,29 @@ export class SimpleDetails extends Vue {
@Prop() private measure!: Measure<MeasureConfig>;
@Prop() private measurements!: Array<Measurement<MeasurementMeta>>;
// private newMeasurement;
private moment = moment;
private chartOptions = {
markers: { size: 6 },
noData: { text: 'no data',
style: { fontSize: '18px' } },
stroke: { curve: 'smooth' },
stroke: { curve: 'straight' },
xaxis: { type: 'datetime' }
};
private get measurementChartData(): ApexAxisChartSeries {
const measurementData = this.measurements || [];
const measurementData = this.measurements.slice() || [];
return [{
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() {
return (this.measurements || []).map((m) => {
return assign({}, m, {
tsDisplay: moment(m.timestamp).format('MMM Do, HH:mm'),
tsDisplay: formatTS(this.measure, m),
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>
<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
:to="'/measures/' + measure.slug">
{{measure.name}}</router-link></h2>
<SimpleSummaryGraph v-if="measure.config.type === 'simple'"
:measure=measure :measurements=measurements />
<ListSummary v-if="measure.config.type === 'list'"
<TextSummary v-if="measure.config.type === 'text'"
:measure=measure :measurements=measurements />
</div>
</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 { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
import { measurementStore } from '@/store';
import ListSummary from './ListSummary.vue';
import TextSummary from './TextSummary.vue';
import SimpleSummaryGraph from './SimpleSummaryGraph.vue';
@Component({
components: {
ListSummary,
TextSummary,
SimpleSummaryGraph
}
})

View File

@ -9,18 +9,20 @@ export class SimpleSummaryGraph extends Vue {
private chartOptions = {
chart: { sparkline: { enabled: true } },
grid: { padding: { top: 20 }},
stroke: { curve: 'smooth' },
stroke: { curve: 'straight' },
noData: { text: 'no data',
style: { fontSize: '18px' } },
xaxis: { type: 'datetime' }
};
private get measurementData(): ApexAxisChartSeries {
const measurementData = this.measurements || [];
const measurementData = this.measurements.slice() || [];
return [{
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>
<SimpleEntry v-if="measure.config.type === 'simple'"
:measure=measure v-model=value />
<TextEntry v-if="measure.config.type === 'text'"
:measure=measure v-model=value />
</div>
</template>
<script lang="ts" src="./measurement-entry.ts"></script>

View File

@ -2,7 +2,9 @@
<fieldset>
<div>
<label for=timestamp>Timestamp</label>
<input type=datetime-local
<input
name=timestamp
type=datetime-local
v-model=value.timestamp
v-show=editTimestamp
:disabled=disabled />
@ -12,7 +14,7 @@
</div>
<div>
<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>
</fieldset>
</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 { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
import SimpleEntry from './SimpleEntry.vue';
import TextEntry from './TextEntry.vue';
@Component({
components: { SimpleEntry }
components: {
SimpleEntry,
TextEntry
}
})
export class MeasurementEntry extends Vue {
@Prop() private measure!: Measure<MeasureConfig>;

View File

@ -5,14 +5,12 @@ import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } fro
export class SimpleEntry extends Vue {
@Prop() public measure!: Measure<MeasureConfig>;
@Prop() public value!: Measurement<MeasurementMeta>;
@Prop() public disabled: boolean = false;
@Prop() public disabled!: boolean;
private editTimestamp: boolean = false;
@Watch('value', { immediate: true, deep: true })
@Emit('input')
private onMeasurementChanged(newVal: Measurement<MeasurementMeta>, oldVal: Measurement<MeasurementMeta>) {
newVal.extData.measureType = 'simple' as MeasureType;
if (typeof(newVal.value) === 'string' ) {
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 {
id: string;
@ -17,9 +17,10 @@ export interface LoginSubmit {
export interface MeasureConfig {
type: MeasureType;
isVisible: boolean;
timestampDisplayFormat: string;
}
export interface ListMeasureConfig extends MeasureConfig {
export interface TextMeasureConfig extends MeasureConfig {
showTimestamp: boolean;
}
@ -33,10 +34,9 @@ export interface Measure<C extends MeasureConfig> {
}
export interface MeasurementMeta {
measureType: MeasureType;
}
export interface ListMeasurementMeta extends MeasurementMeta {
export interface TextMeasurementMeta extends MeasurementMeta {
entry: string;
}

View File

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

View File

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

View File

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

View File

@ -32,7 +32,7 @@ export class AuthStoreModule extends VuexModule {
// this should be guaranteed by the server (redirect HTTP -> HTTPS)
// but we'll do a sanity check just to make sure.
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);
}

View File

@ -6,7 +6,9 @@ import {
MutationAction,
VuexModule
} from 'vuex-module-decorators';
import assign from 'lodash.assign';
import keyBy from 'lodash.keyby';
import omit from 'lodash.omit';
import { User, Measure, MeasureConfig } from '@/models';
import api from '@/services/pm-api-client';
@ -28,13 +30,29 @@ export class MeasureStoreModule extends VuexModule {
}
@Action({ rawError: true })
public async createMeasure(m: Measure<MeasureConfig>) {
public async createMeasure<T extends MeasureConfig>(m: Measure<T>) {
const newMeasure = await api.createMeasure(m);
this.context.commit('SET_MEASURE', 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>) {
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 index = findIndex(existing, { id: measurement.id });
if (index > 0) { newMeasurements.push(measurement); }
if (index < 0) { newMeasurements.push(measurement); }
else { newMeasurements[index] = measurement; }
this.measurements = assign({}, this.measurements, { [measure.id]: newMeasurements });
}

View File

@ -1,7 +1,9 @@
@import '~@/styles/vars';
button,
.btn,
.btn-action {
.btn-action,
.btn-icon {
border: 0;
border-radius: .25em;
cursor: pointer;
@ -13,14 +15,27 @@
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 {
background-color: $color2;
color: $color3;
cursor: pointer;
position: relative;
&:hover {
background-color: darken($color2, 5%);
&:hover, &:focus {
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>
<h2>{{measure.description}}</h2>
</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>
<MeasureDetails :measure=measure :measurements=measurements />
</div>
@ -16,4 +35,4 @@
</div>
</template>
<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>
<MeasureSummary
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 />
<!--<MeasureSummary
v-for="(measure, slug) in measures"
:key="slug"
:measure=measure />-->
</div>
</div>
</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';

View File

@ -1,8 +1,14 @@
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 { measureStore, measurementStore } from '@/store';
import MeasureDetails from '@/components/measure-details/MeasureDetails.vue';
library.add(faPencilAlt);
library.add(faTrash);
@Component({
components: {
MeasureDetails

View File

@ -5,6 +5,7 @@ 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';
library.add(faSync);
@ -19,7 +20,8 @@ export class NewMeasure extends Vue {
id: '',
config: {
type: 'simple' as MeasureType,
isVisible: true
isVisible: true,
timestampDisplayFormat: 'l'
},
description: '',
name: '',
@ -28,19 +30,12 @@ export class NewMeasure extends Vue {
};
private get slugFromName() {
return this.slugify(this.measure.name);
}
private slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^\w\s\-]/g, '')
.replace(/\s+/g, '-');
return slugify(this.measure.name);
}
private async createMeasure() {
if (!this.measure.slug) {
this.measure.slug = this.slugify(this.measure.name);
this.measure.slug = slugify(this.measure.name);
}
this.waiting = true;

View File

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

View File

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

View File

@ -8,7 +8,7 @@ const VERSION = {
module.exports = {
devServer: {
proxy: {
'/api': { target: 'http://localhost:8081' }
'/v0': { target: 'http://localhost:8081' }
},
host: 'localhost',
disableHostCheck: true