Compare commits

...

38 Commits
0.8.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
84 changed files with 1383 additions and 438 deletions

2
.gitignore vendored
View File

@ -3,6 +3,8 @@ api/personal_measure_api
api/postgres.container.id api/postgres.container.id
api/src/main/nim/personal_measure_api api/src/main/nim/personal_measure_api
api/src/main/nim/personal_measure_apipkg/db api/src/main/nim/personal_measure_apipkg/db
api/personal_measure_api.config.dev.json
api/personal_measure_api.config.prod.json
.DS_Store .DS_Store
node_modules node_modules

View File

@ -5,14 +5,12 @@ build: dist/personal-measure-api.tar.gz dist/personal-measure-web.tar.gz
clean: clean:
-rm -r dist -rm -r dist
-rm api/personal_measure_api
-rm -r web/dist -rm -r web/dist
-docker container prune
-docker image prune
dist/personal-measure-api.tar.gz: update-version:
-mkdir dist operations/update-version.sh
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
dist/personal-measure-web.tar.gz: dist/personal-measure-web.tar.gz:
-mkdir dist -mkdir dist
@ -20,18 +18,14 @@ dist/personal-measure-web.tar.gz:
tar czf dist/personal-measure-web-${VERSION}.tar.gz -C web/dist . tar czf dist/personal-measure-web-${VERSION}.tar.gz -C web/dist .
cp dist/personal-measure-web-${VERSION}.tar.gz dist/personal-measure-web.tar.gz cp dist/personal-measure-web-${VERSION}.tar.gz dist/personal-measure-web.tar.gz
deploy-api: dist/personal-measure-api.tar.gz deploy-api:
mkdir -p temp-deploy/personal-measure-api-${VERSION} make -C api personal_measure_api-image push-image
tar xzf dist/personal-measure-api-${VERSION}.tar.gz -C temp-deploy/personal-measure-api-${VERSION} cd operations/terraform && terraform apply -target module.${TARGET_ENV}_env.aws_ecs_task_definition.pmapi -target module.${TARGET_ENV}_env.aws_ecs_service.pmapi
-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-web: dist/personal-measure-web.tar.gz deploy-web: dist/personal-measure-web.tar.gz
mkdir -p temp-deploy/personal-measure-web-${VERSION} mkdir -p temp-deploy/personal-measure-web-${VERSION}
tar xzf dist/personal-measure-web-${VERSION}.tar.gz -C 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 TARGET_ENV=${TARGET_ENV} operations/invalidate-cdn-cache.sh
rm -r temp-deploy 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 MAINTAINER jonathan@jdbernard.com
# TODO: install db_migrate so we can use it below
# RUN nimble install https://git.jdb-labs.com/jdb/db-migrate.git
#RUN apt-get install -y libssl-dev
COPY personal_measure_api.nimble /pm-api/ COPY personal_measure_api.nimble /pm-api/
COPY src /pm-api/src COPY src /pm-api/src
WORKDIR /pm-api WORKDIR /pm-api
RUN nimble build -y RUN nimble build -y
FROM alpine FROM alpine
#RUN apt-get install -y postgresql-client EXPOSE 80
RUN apk -v --update add --no-cache \ RUN apk -v --update add --no-cache \
ca-certificates \ ca-certificates \
libressl2.7-libssl \ libcrypto1.1 \
libressl2.7-libcrypto \ libssl1.1 \
pcre \ pcre \
postgresql-client postgresql-client
COPY --from=build /pm-api/personal_measure_api / COPY --from=build /pm-api/personal_measure_api /
COPY personal_measure_api.config.prod.json /personal_measure_api.config.json COPY personal_measure_api.config.docker.json /personal_measure_api.config.json
CMD ["/personal_measure_api", "serve"] CMD ["/personal_measure_api", "serve"]
# TODO: replace the above with something like: # TODO: replace the above with something like:

View File

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

30
api/README.md Normal file
View File

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

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

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
"authSecret":"bifekHuffIs3", "authSecret":"bifekHuffIs3",
"dbConnString":"host=localhost port=5500 dbname=personal_measure user=postgres password=password", "dbConnString":"host=localhost port=5500 dbname=personal_measure user=postgres password=password",
"debug":true, "debug":true,
"port":8081, "port":8100,
"pwdCost":11, "pwdCost":11,
"knownOrigins": [ "https://curl.localhost" ] "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" include "src/main/nim/personal_measure_apipkg/version.nim"
version = "0.8.0" version = "0.11.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "JDB\'s Personal Measures API" description = "JDB\'s Personal Measures API"
license = "MIT" license = "MIT"
@ -16,6 +16,6 @@ skipExt = @["nim"]
requires @["nim >= 0.19.4", "bcrypt", "docopt >= 0.6.8", "isaac >= 0.1.3", requires @["nim >= 0.19.4", "bcrypt", "docopt >= 0.6.8", "isaac >= 0.1.3",
"jester >= 0.4.3", "jwt", "tempfile", "uuids >= 0.1.10" ] "jester >= 0.4.3", "jwt", "tempfile", "uuids >= 0.1.10" ]
requires "https://git.jdb-labs.com/jdb/nim-cli-utils.git >= 0.6.3" requires "https://git.jdb-software.com/jdb/nim-cli-utils.git >= 0.6.3"
requires "https://git.jdb-labs.com/jdb/nim-time-utils.git >= 0.5.2" requires "https://git.jdb-software.com/jdb/nim-time-utils.git >= 0.5.2"
requires "https://git.jdb-labs.com/jdb-labs/fiber-orm-nim.git >= 0.3.0" requires "https://git.jdb-software.com/jdb-software/fiber-orm-nim.git >= 0.3.2"

View File

@ -27,26 +27,22 @@ proc loadConfig*(args: Table[string, docopt.Value] = initTable[string, docopt.Va
try: json = parseFile(filePath) try: json = parseFile(filePath)
except: except:
json = %DEFAULT_CONFIG json = %DEFAULT_CONFIG
if not existsFile(filePath): if not fileExists(filePath):
info "created new configuration file \"" & filePath & "\"" info "created new configuration file \"" & filePath & "\""
filePath.writeFile($json) filePath.writeFile($json)
else: else:
warn "Cannot read configuration file \"" & filePath & "\":\n\t" & warn "Cannot read configuration file \"" & filePath & "\":\n\t" &
getCurrentExceptionMsg() getCurrentExceptionMsg()
let knownOriginsArray =
if json.hasKey("knownOrigins"): json["knownOrigins"]
else: newJArray()
let cfg = CombinedConfig(docopt: args, json: json) let cfg = CombinedConfig(docopt: args, json: json)
result = PMApiConfig( result = PMApiConfig(
authSecret: cfg.getVal("auth-secret"), authSecret: cfg.getVal("auth-secret"),
dbConnString: cfg.getVal("db-conn-string"), dbConnString: cfg.getVal("db-conn-string"),
debug: "true".startsWith(cfg.getVal("debug", "false").toLower()), debug: "true".startsWith(cfg.getVal("debug", "false").toLower()),
port: parseInt(cfg.getVal("port", "8080")), port: parseInt(cfg.getVal("port", "8100")),
pwdCost: cast[int8](parseInt(cfg.getVal("pwd-cost", "11"))), pwdCost: cast[int8](parseInt(cfg.getVal("pwd-cost", "11"))),
knownOrigins: 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 = proc initContext(args: Table[string, docopt.Value]): PMApiContext =
@ -114,6 +110,6 @@ Options:
if args["serve"]: start(ctx) if args["serve"]: start(ctx)
except: except:
fatal "pit: " & getCurrentExceptionMsg() fatal "personal_measure_api: " & getCurrentExceptionMsg()
#raise getCurrentException() #raise getCurrentException()
quit(QuitFailure) quit(QuitFailure)

View File

@ -1,5 +1,6 @@
import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils, import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils,
times, uuids times, uuids
from httpcore import HttpMethod
from unicode import capitalize from unicode import capitalize
import strutils except capitalize import strutils except capitalize
import timeutils import timeutils
@ -58,6 +59,29 @@ template jsonResp(code: HttpCode, body: string = "", headersToSend: RawHeaders =
body 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 jsonResp(body: string) = jsonResp(Http200, body)
template statusResp(code: HttpCode, details: string = "", headersToSend: RawHeaders = @{:} ) = template statusResp(code: HttpCode, details: string = "", headersToSend: RawHeaders = @{:} ) =
@ -97,7 +121,7 @@ proc fromJWT*(ctx: PMApiContext, strTok: string): Session =
## Validate a given JWT and extract the session data. ## Validate a given JWT and extract the session data.
let jwt = toJWT(strTok) let jwt = toJWT(strTok)
var secret = ctx.cfg.authSecret var secret = ctx.cfg.authSecret
if not jwt.verify(secret): raiseEx "Unable to verify auth token." if not jwt.verify(secret, HS256): raiseEx "Unable to verify auth token."
jwt.verifyTimeClaims() jwt.verifyTimeClaims()
# Find the user record (if authenticated) # Find the user record (if authenticated)
@ -212,9 +236,13 @@ proc start*(ctx: PMApiContext): void =
routes: routes:
options "/version": optionsResp(@[HttpGet])
get "/version": get "/version":
jsonResp($(%("personal_measure_api v" & PM_API_VERSION))) jsonResp($(%("personal_measure_api v" & PM_API_VERSION)))
options "/auth-token": optionsResp(@[HttpPost])
post "/auth-token": post "/auth-token":
try: try:
@ -226,6 +254,8 @@ proc start*(ctx: PMApiContext): void =
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg()) except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except: statusResp(Http401, getCurrentExceptionMsg()) except: statusResp(Http401, getCurrentExceptionMsg())
options "/change-pwd": optionsResp(@[HttpPost])
post "/change-pwd": post "/change-pwd":
checkAuth() checkAuth()
@ -247,6 +277,8 @@ proc start*(ctx: PMApiContext): void =
error "internal error changing password: " & getCurrentExceptionMsg() error "internal error changing password: " & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
options "/change-pwd/@userId": optionsResp(@[HttpPost])
post "/change-pwd/@userId": post "/change-pwd/@userId":
checkAuth(true) checkAuth(true)
@ -268,6 +300,8 @@ proc start*(ctx: PMApiContext): void =
error "internal error changing password: " & getCurrentExceptionMsg() error "internal error changing password: " & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
options "/user": optionsResp(@[HttpGet, HttpPut])
get "/user": get "/user":
checkAuth() checkAuth()
@ -292,6 +326,8 @@ proc start*(ctx: PMApiContext): void =
error "Could not update user information:\n\t" & getCurrentExceptionMsg() error "Could not update user information:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
options "/users": optionsResp(@[HttpGet, HttpPost])
get "/users": get "/users":
checkAuth(true) checkAuth(true)
@ -320,6 +356,8 @@ proc start*(ctx: PMApiContext): void =
error "Could not create new user:\n\t" & getCurrentExceptionMsg() error "Could not create new user:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
options "/users/@userId": optionsResp(@[HttpGet, HttpDelete])
get "/users/@userId": get "/users/@userId":
checkAuth(true) checkAuth(true)
@ -340,6 +378,8 @@ proc start*(ctx: PMApiContext): void =
except: statusResp(Http500, getCurrentExceptionMsg()) except: statusResp(Http500, getCurrentExceptionMsg())
options "/api-tokens": optionsResp(@[HttpGet, HttpPost])
get "/api-tokens": get "/api-tokens":
checkAuth() checkAuth()
@ -374,6 +414,8 @@ proc start*(ctx: PMApiContext): void =
debug getCurrentExceptionMsg() debug getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
options "/api-tokens/@tokenId": optionsResp(@[HttpGet, HttpDelete])
get "/api-tokens/@tokenId": get "/api-tokens/@tokenId":
checkAuth() checkAuth()
@ -392,6 +434,10 @@ proc start*(ctx: PMApiContext): void =
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg()) except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: statusResp(Http500) except: statusResp(Http500)
# Measure
options "/measures": optionsResp(@[HttpGet, HttpPost])
get "/measures": get "/measures":
checkAuth() checkAuth()
@ -436,6 +482,8 @@ proc start*(ctx: PMApiContext): void =
error "unable to create new measure:\n\t" & getCurrentExceptionMsg() error "unable to create new measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
options "/measures/@slug": optionsResp(@[HttpGet, HttpPost, HttpDelete])
get "/measures/@slug": get "/measures/@slug":
checkAuth() checkAuth()
@ -445,6 +493,37 @@ proc start*(ctx: PMApiContext): void =
error "unable to look up a measure by id:\n\t" & getCurrentExceptionMsg() error "unable to look up a measure by id:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
post "/measures/@slug":
checkAuth()
try:
let jsonBody = parseJson(request.body)
var existingMeasure = ctx.getMeasureForSlug(session.user.id, @"slug")
if not (jsonBody.hasKey("slug") or jsonBody.hasKey("name")):
raiseEx BadRequestError, "body must contain either the 'slug' field (short name), or the 'name' field, or both"
existingMeasure.slug =
if jsonBody.hasKey("slug"): jsonBody["slug"].getStr.nameToSlug
else: jsonBody["name"].getStr.nameToSlug
existingMeasure.name =
if jsonBody.hasKey("name"): jsonBody["name"].getStr
else: jsonBody["slug"].getStr.capitalize
if jsonBody.hasKey("config"): existingMeasure.config = jsonBody["config"]
if jsonBody.hasKey("description"): existingMeasure.description = jsonBody["description"].getStr
jsonResp($(%ctx.db.updateMeasure(existingMeasure)))
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except:
error "unable to update measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
delete "/measures/@slug": delete "/measures/@slug":
checkAuth() checkAuth()
@ -457,7 +536,11 @@ proc start*(ctx: PMApiContext): void =
error "unable to delete a measure:\n\t" & getCurrentExceptionMsg() error "unable to delete a measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
get "/measure/@slug": # Measurements
options "/measurements/@slug": optionsResp(@[HttpGet, HttpPost])
get "/measurements/@slug":
checkAuth() checkAuth()
try: try:
@ -468,7 +551,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to list measurements:\n\t" & getCurrentExceptionMsg() error "unable to list measurements:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
post "/measure/@slug": post "/measurements/@slug":
checkAuth() checkAuth()
try: try:
@ -494,7 +577,9 @@ proc start*(ctx: PMApiContext): void =
error "unable to add measurement:\n\t" & getCurrentExceptionMsg() error "unable to add measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
get "/measure/@slug/@id": options "/measurements/@slug/@id": optionsResp(@[HttpGet, HttpPut, HttpDelete])
get "/measurements/@slug/@id":
checkAuth() checkAuth()
try: try:
@ -509,7 +594,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg() error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
put "/measure/@slug/@id": put "/measurements/@slug/@id":
checkAuth() checkAuth()
try: try:
@ -529,7 +614,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg() error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
delete "/measure/@slug/@id": delete "/measurements/@slug/@id":
checkAuth() checkAuth()
try: try:
@ -546,6 +631,8 @@ proc start*(ctx: PMApiContext): void =
error "unable to delete measurement:\n\t" & getCurrentExceptionMsg() error "unable to delete measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
options "/log": optionsResp(@[HttpPost])
post "/log": post "/log":
checkAuth() checkAuth()
@ -563,6 +650,8 @@ proc start*(ctx: PMApiContext): void =
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg()) except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except: statusResp(Http500, getCurrentExceptionMsg()) except: statusResp(Http500, getCurrentExceptionMsg())
options "/log/batch": optionsResp(@[HttpPost])
post "/log/batch": post "/log/batch":
checkAuth() checkAuth()

View File

@ -1,4 +1,4 @@
import db_postgres, fiber_orm, uuids import db_postgres, fiber_orm, sequtils, uuids
import ./models import ./models
@ -8,7 +8,7 @@ type
PMApiDb* = ref object PMApiDb* = ref object
conn: DbConn conn: DbConn
proc connect*(connString: string): PMApiDb = proc connect*(connString: string): PMApiDb =
result = PMApiDb(conn: open("", "", "", connString)) result = PMApiDb(conn: open("", "", "", connString))

View File

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

View File

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

@ -1,33 +0,0 @@
server {
listen 80;
server_name pmapi-dev.jdb-labs.com;
return 301 https://pmapi-dev.jdb-labs.com$request_uri;
}
server {
listen 443;
server_name pmapi-dev.jdb-labs.com;
ssl on;
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://pm-dev.jdb-labs.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
return 204;
}
proxy_pass http://localhost:8281;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

View File

@ -1,33 +0,0 @@
server {
listen 80;
server_name pmapi.jdb-labs.com;
return 301 https://pmapi.jdb-labs.com$request_uri;
}
server {
listen 443;
server_name pmapi.jdb-labs.com;
ssl on;
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://pm.jdb-labs.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
return 204;
}
proxy_pass http://localhost:8280;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

View File

@ -7,5 +7,5 @@ variable "aws_region" {
variable "app_root_url" { variable "app_root_url" {
description = "Name of the S3 bucket to store deployed artifacts, logs, etc." description = "Name of the S3 bucket to store deployed artifacts, logs, etc."
default = "pm.jdb-labs.com" default = "pm.jdb-software.com"
} }

View File

@ -6,18 +6,18 @@ data "aws_iam_policy_document" "bucket_access_policy" {
principals { principals {
type = "AWS" 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 { statement {
actions = [ "s3:ListBucket" ] actions = [ "s3:ListBucket" ]
effect = "Allow" effect = "Allow"
resources = [ "${var.artifact_bucket.arn}" ] resources = [ var.artifact_bucket.arn ]
principals { principals {
type = "AWS" 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 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" { resource "aws_cloudfront_origin_access_identity" "origin_access_identity" {
comment = "OAI for Personal Measure {$var.environment} environment." comment = "OAI for Personal Measure {$var.environment} environment."
} }
resource "aws_cloudfront_distribution" "s3_distribution" { resource "aws_cloudfront_distribution" "s3_distribution" {
origin { 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_id = "S3-PersonalMeasure-${var.environment}"
origin_path = "/${var.environment}/webroot" origin_path = "/${var.environment}/webroot"
s3_origin_config { 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 { logging_config {
include_cookies = false include_cookies = false
bucket = "${var.artifact_bucket.bucket_domain_name}" bucket = var.artifact_bucket.bucket_domain_name
prefix = "${var.environment}/logs/cloudfront" prefix = "${var.environment}/logs/cloudfront"
} }
aliases = ["${local.env_domain_name}"] aliases = [local.app_domain_name]
default_cache_behavior { default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"] allowed_methods = ["GET", "HEAD", "OPTIONS"]
@ -92,11 +88,11 @@ resource "aws_cloudfront_distribution" "s3_distribution" {
} }
} }
tags = { tags = {
Environment = "${var.environment}" Environment = local.environment_name
} }
viewer_certificate { 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" 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." description = "The aws_s3_bucket object representing the artifact bucket where deployed artifacts, logs, etc. live."
} }
variable "cloudfront_ssl_certificate_arn" { variable "ecr_repo" {
description = "ARN of the managed SSL certificate to use for this environment." 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

@ -3,40 +3,24 @@ provider "aws" {
} }
resource "aws_s3_bucket" "personal_measure" { resource "aws_s3_bucket" "personal_measure" {
bucket = "${var.app_root_url}" bucket = var.app_root_url
acl = "log-delivery-write" acl = "log-delivery-write"
} }
resource "aws_dynamodb_table" "dynamodb_terraform-state-lock" {
name = "terraform-state-lock.${var.app_root_url}"
hash_key = "LockID"
read_capacity = 20
write_capacity = 20
attribute {
name = "LockID"
type = "S"
}
tags = {
Name = "Terraform DynamoDB State Lock Table"
}
}
module "dev_env" { module "dev_env" {
source = "./deployed_env" source = "./deployed_env"
environment = "dev" environment = "dev"
artifact_bucket = aws_s3_bucket.personal_measure 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" { module "prod_env" {
source = "./deployed_env" source = "./deployed_env"
environment = "prod" environment = "prod"
artifact_bucket = aws_s3_bucket.personal_measure 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" { data "aws_iam_policy_document" "cloudfront_access_policy" {
@ -45,6 +29,6 @@ data "aws_iam_policy_document" "cloudfront_access_policy" {
} }
resource "aws_s3_bucket_policy" "personal_measure" { resource "aws_s3_bucket_policy" "personal_measure" {
bucket = "${aws_s3_bucket.personal_measure.id}" bucket = aws_s3_bucket.personal_measure.id
policy = "${data.aws_iam_policy_document.cloudfront_access_policy.json}" policy = data.aws_iam_policy_document.cloudfront_access_policy.json
} }

View File

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

View File

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

View File

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

353
web/package-lock.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "personal-measure-web", "name": "personal-measure-web",
"version": "0.8.0", "version": "0.11.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -829,30 +829,30 @@
} }
}, },
"@fortawesome/fontawesome-common-types": { "@fortawesome/fontawesome-common-types": {
"version": "0.2.27", "version": "0.2.31",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.27.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.31.tgz",
"integrity": "sha512-97GaByGaXDGMkzcJX7VmR/jRJd8h1mfhtA7RsxDBN61GnWE/PPCZhOdwG/8OZYktiRUF0CvFOr+VgRkJrt6TWg==" "integrity": "sha512-xfnPyH6NN5r/h1/qDYoGB0BlHSID902UkQzxR8QsoKDh55KAPr8ruAoie12WQEEQT8lRE2wtV7LoUllJ1HqCag=="
}, },
"@fortawesome/fontawesome-svg-core": { "@fortawesome/fontawesome-svg-core": {
"version": "1.2.27", "version": "1.2.31",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.27.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.31.tgz",
"integrity": "sha512-sOD3DKynocnHYpuw2sLPnTunDj7rLk91LYhi2axUYwuGe9cPCw7Bsu9EWtVdNJP+IYgTCZIbyARKXuy5K/nv+Q==", "integrity": "sha512-lqUWRK+ylHQJG5Kiez4XrAZAfc7snxCc+X59quL3xPfMnxzfyf1lt+/hD7X1ZL4KlzAH2KFzMuEVrolo/rAkog==",
"requires": { "requires": {
"@fortawesome/fontawesome-common-types": "^0.2.27" "@fortawesome/fontawesome-common-types": "^0.2.31"
} }
}, },
"@fortawesome/free-solid-svg-icons": { "@fortawesome/free-solid-svg-icons": {
"version": "5.12.1", "version": "5.15.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.12.1.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.0.tgz",
"integrity": "sha512-k3MwRFFUhyL4cuCJSaHDA0YNYMELDXX0h8JKtWYxO5XD3Dn+maXOMrVAAiNGooUyM2v/wz/TOaM0jxYVKeXX7g==", "integrity": "sha512-4dGRsOnGBPM7c0fd3LuiU6LgRSLn01rw1LJ370yC2iFMLUcLCLLynZhQbMhsiJmMwQM/YmPQblAdyHKVCgsIAA==",
"requires": { "requires": {
"@fortawesome/fontawesome-common-types": "^0.2.27" "@fortawesome/fontawesome-common-types": "^0.2.31"
} }
}, },
"@fortawesome/vue-fontawesome": { "@fortawesome/vue-fontawesome": {
"version": "0.1.9", "version": "0.1.10",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-0.1.9.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-0.1.10.tgz",
"integrity": "sha512-h/emhmZz+DfB2zOGLWawNwXq82UYhn9waTfUjLLmeaIqtnIyNt6kYlpQT/vzJjLZRDRvY2IEJAh1di5qKpKVpA==" "integrity": "sha512-b2+SLF31h32LSepVcXe+BQ63yvbq5qmTCy4KfFogCYm2bn68H5sDWUnX+U7MBqnM2aeEk9M7xSoqGnu+wSdY6w=="
}, },
"@hapi/address": { "@hapi/address": {
"version": "2.1.4", "version": "2.1.4",
@ -1012,9 +1012,9 @@
"dev": true "dev": true
}, },
"@types/js-cookie": { "@types/js-cookie": {
"version": "2.2.4", "version": "2.2.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.4.tgz", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.6.tgz",
"integrity": "sha512-WTfSE1Eauak/Nrg6cA9FgPTFvVawejsai6zXoq0QYTQ3mxONeRtGhKxa7wMlUzWWmzrmTeV+rwLjHgsCntdrsA==" "integrity": "sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw=="
}, },
"@types/jwt-decode": { "@types/jwt-decode": {
"version": "2.2.1", "version": "2.2.1",
@ -1059,6 +1059,14 @@
"@types/lodash": "*" "@types/lodash": "*"
} }
}, },
"@types/lodash.omit": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/@types/lodash.omit/-/lodash.omit-4.5.6.tgz",
"integrity": "sha512-KXPpOSNX2h0DAG2w7ajpk7TXvWF28ZHs5nJhOJyP0BQHkehgr948RVsToItMme6oi0XJkp19CbuNXkIX8FiBlQ==",
"requires": {
"@types/lodash": "*"
}
},
"@types/minimatch": { "@types/minimatch": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@ -1500,9 +1508,9 @@
"dev": true "dev": true
}, },
"@vue/test-utils": { "@vue/test-utils": {
"version": "1.0.0-beta.31", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-1.0.0-beta.31.tgz", "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-1.1.0.tgz",
"integrity": "sha512-IlhSx5hyEVnbvDZ3P98R1jNmy88QAd/y66Upn4EcvxSD5D4hwOutl3dIdfmSTSXs4b9DIMDnEVjX7t00cvOnvg==", "integrity": "sha512-M+3jtVqNYIrvzO5gaxogre5a5+96h0hN/dXw+5Lj0t+dp6fAhYcUjpLrC9j9cEEkl2Rcuh/gKYRUmR5N4vcqPw==",
"dev": true, "dev": true,
"requires": { "requires": {
"dom-event-types": "^1.0.0", "dom-event-types": "^1.0.0",
@ -1872,16 +1880,16 @@
"dev": true "dev": true
}, },
"apexcharts": { "apexcharts": {
"version": "3.15.6", "version": "3.21.0",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.15.6.tgz", "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.21.0.tgz",
"integrity": "sha512-8mZqg7eTZGU2zvjYUUOf+sTqgfmutipHU9lNgkqzZPtwIVGwR5PwXTBNKRJSI3AeSoQ8VZGYfzTJWoUDfGAeBw==", "integrity": "sha512-yeulUZCTG57swbJ5oIJIjgfRdIsvmC/2WJanrZxNGhjtZf2B9NaT95pEtbrml1BILJKtMn4VbpXVZp+8Tzmydg==",
"requires": { "requires": {
"svg.draggable.js": "^2.2.2", "svg.draggable.js": "^2.2.2",
"svg.easing.js": "^2.0.0", "svg.easing.js": "^2.0.0",
"svg.filter.js": "^2.0.2", "svg.filter.js": "^2.0.2",
"svg.pathmorphing.js": "^0.1.3", "svg.pathmorphing.js": "^0.1.3",
"svg.resize.js": "^1.4.3", "svg.resize.js": "^1.4.3",
"svg.select.js": "^2.1.2" "svg.select.js": "^3.0.1"
} }
}, },
"append-transform": { "append-transform": {
@ -6976,9 +6984,9 @@
} }
}, },
"globule": { "globule": {
"version": "1.3.0", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/globule/-/globule-1.3.0.tgz", "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.2.tgz",
"integrity": "sha512-YlD4kdMqRCQHrhVdonet4TdRtv1/sZKepvoxNT4Nrhrp5HI8XFfc8kFlGlBn2myBo80aGp8Eft259mbcUJhgSg==", "integrity": "sha512-7IDTQTIu2xzXkT+6mlluidnWo+BypnbSoEVVQCGfzqnl5Ik8d3e1d4wycb8Rj9tWW+Z39uPWsdlquqiqPCd/pA==",
"dev": true, "dev": true,
"requires": { "requires": {
"glob": "~7.1.1", "glob": "~7.1.1",
@ -7590,9 +7598,9 @@
"dev": true "dev": true
}, },
"in-publish": { "in-publish": {
"version": "2.0.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.0.tgz", "resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.1.tgz",
"integrity": "sha1-4g/146KvwmkDILbcVSaCqcf631E=", "integrity": "sha512-oDM0kUSNFC31ShNxHKUyfZKy8ZeXZBWMjMdZHKLOk13uvT27VTL/QzRGfRUcevJhpkZAvlhPYuXkF7eNWrtyxQ==",
"dev": true "dev": true
}, },
"indent-string": { "indent-string": {
@ -7666,12 +7674,6 @@
"loose-envify": "^1.0.0" "loose-envify": "^1.0.0"
} }
}, },
"invert-kv": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz",
"integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=",
"dev": true
},
"ip": { "ip": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
@ -9268,9 +9270,9 @@
} }
}, },
"js-base64": { "js-base64": {
"version": "2.5.1", "version": "2.6.4",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.1.tgz", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz",
"integrity": "sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw==", "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==",
"dev": true "dev": true
}, },
"js-beautify": { "js-beautify": {
@ -9463,9 +9465,9 @@
"integrity": "sha1-fYa9VmefWM5qhHBKZX3TkruoGnk=" "integrity": "sha1-fYa9VmefWM5qhHBKZX3TkruoGnk="
}, },
"keen-ui": { "keen-ui": {
"version": "1.2.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/keen-ui/-/keen-ui-1.2.1.tgz", "resolved": "https://registry.npmjs.org/keen-ui/-/keen-ui-1.3.1.tgz",
"integrity": "sha512-SyF3orIjl098Du4b1UoXNDmdASxn/hsn7NO0JSoDI4LKmFUs8dP9uywfK+QEnDCev73jSZ3tdJELJKOjV/dl3Q==", "integrity": "sha512-2EAZy2YFdthCRtZvDHXvMZUTwvHda70WcjbEUaJKM1oH5q9rgecL80VBsTmmcIvfuratIEisBBiteojw3XEa5g==",
"requires": { "requires": {
"autosize": "^3.0.20", "autosize": "^3.0.20",
"deepmerge": "^2.0.1", "deepmerge": "^2.0.1",
@ -9518,15 +9520,6 @@
"launch-editor": "^2.2.1" "launch-editor": "^2.2.1"
} }
}, },
"lcid": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz",
"integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=",
"dev": true,
"requires": {
"invert-kv": "^1.0.0"
}
},
"left-pad": { "left-pad": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
@ -9991,6 +9984,11 @@
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
}, },
"lodash.omit": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz",
"integrity": "sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA="
},
"lodash.sortby": { "lodash.sortby": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
@ -10485,9 +10483,9 @@
} }
}, },
"moment": { "moment": {
"version": "2.24.0", "version": "2.29.0",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.0.tgz",
"integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" "integrity": "sha512-z6IJ5HXYiuxvFTI6eiQ9dm77uE0gyy1yXNApVHqTcnIKfY9tIwEjlzsZ6u1LQXvVgKeTnv9Xm7NDvJ7lso3MtA=="
}, },
"morgan": { "morgan": {
"version": "1.9.1", "version": "1.9.1",
@ -10773,9 +10771,9 @@
} }
}, },
"node-sass": { "node-sass": {
"version": "4.13.1", "version": "4.14.1",
"resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.13.1.tgz", "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.14.1.tgz",
"integrity": "sha512-TTWFx+ZhyDx1Biiez2nB0L3YrCZ/8oHagaDalbuBSlqXgUPsdkUSzJsVxeDO9LtPB49+Fh3WQl3slABo6AotNw==", "integrity": "sha512-sjCuOlvGyCJS40R8BscF5vhVlQjNN069NtQ1gSxyK1u9iqvn6tf7O1R4GNowVZfiZUCRt5MmMs1xd+4V/7Yr0g==",
"dev": true, "dev": true,
"requires": { "requires": {
"async-foreach": "^0.1.3", "async-foreach": "^0.1.3",
@ -10792,7 +10790,7 @@
"node-gyp": "^3.8.0", "node-gyp": "^3.8.0",
"npmlog": "^4.0.0", "npmlog": "^4.0.0",
"request": "^2.88.0", "request": "^2.88.0",
"sass-graph": "^2.2.4", "sass-graph": "2.2.5",
"stdout-stream": "^1.4.0", "stdout-stream": "^1.4.0",
"true-case-path": "^1.0.2" "true-case-path": "^1.0.2"
}, },
@ -10843,9 +10841,9 @@
} }
}, },
"nan": { "nan": {
"version": "2.14.0", "version": "2.14.1",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz",
"integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", "integrity": "sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==",
"dev": true "dev": true
}, },
"strip-ansi": { "strip-ansi": {
@ -12717,9 +12715,9 @@
} }
}, },
"register-service-worker": { "register-service-worker": {
"version": "1.6.2", "version": "1.7.1",
"resolved": "https://registry.npmjs.org/register-service-worker/-/register-service-worker-1.6.2.tgz", "resolved": "https://registry.npmjs.org/register-service-worker/-/register-service-worker-1.7.1.tgz",
"integrity": "sha512-I8L87fX2TK29LDx+wgyOUh2BJ3rDIRC1FtRZEHeP3rivzDv6p1DDZLGGtPucqjEkm45+2crtFIFssEWv56+9Wg==" "integrity": "sha512-IdTfUZ4u8iJL8o1w8es8l6UMGPmkwHolUdT+UmM1UypC80IB4KbpuIlvwWVj8UDS7eJwkEYRcKRgfRX+oTmJsw=="
}, },
"regjsgen": { "regjsgen": {
"version": "0.5.1", "version": "0.5.1",
@ -13053,118 +13051,137 @@
} }
}, },
"sass-graph": { "sass-graph": {
"version": "2.2.4", "version": "2.2.5",
"resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.4.tgz", "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.5.tgz",
"integrity": "sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k=", "integrity": "sha512-VFWDAHOe6mRuT4mZRd4eKE+d8Uedrk6Xnh7Sh9b4NGufQLQjOrvf/MQoOdx+0s92L89FeyUUNfU597j/3uNpag==",
"dev": true, "dev": true,
"requires": { "requires": {
"glob": "^7.0.0", "glob": "^7.0.0",
"lodash": "^4.0.0", "lodash": "^4.0.0",
"scss-tokenizer": "^0.2.3", "scss-tokenizer": "^0.2.3",
"yargs": "^7.0.0" "yargs": "^13.3.2"
}, },
"dependencies": { "dependencies": {
"ansi-regex": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
"dev": true
},
"camelcase": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz",
"integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=",
"dev": true
},
"cliui": { "cliui": {
"version": "3.2.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
"integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==",
"dev": true, "dev": true,
"requires": { "requires": {
"string-width": "^1.0.1", "string-width": "^3.1.0",
"strip-ansi": "^3.0.1", "strip-ansi": "^5.2.0",
"wrap-ansi": "^2.0.0" "wrap-ansi": "^5.1.0"
} }
}, },
"is-fullwidth-code-point": { "emoji-regex": {
"version": "1.0.0", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
"dev": true
},
"find-up": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
"integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
"dev": true, "dev": true,
"requires": { "requires": {
"number-is-nan": "^1.0.0" "locate-path": "^3.0.0"
} }
}, },
"os-locale": { "get-caller-file": {
"version": "1.4.0", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true
},
"locate-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
"integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
"dev": true, "dev": true,
"requires": { "requires": {
"lcid": "^1.0.0" "p-locate": "^3.0.0",
"path-exists": "^3.0.0"
} }
}, },
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
"integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
"dev": true,
"requires": {
"p-limit": "^2.0.0"
}
},
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true
},
"require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true
},
"string-width": { "string-width": {
"version": "1.0.2", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
"dev": true, "dev": true,
"requires": { "requires": {
"code-point-at": "^1.0.0", "emoji-regex": "^7.0.1",
"is-fullwidth-code-point": "^1.0.0", "is-fullwidth-code-point": "^2.0.0",
"strip-ansi": "^3.0.0" "strip-ansi": "^5.1.0"
} }
}, },
"strip-ansi": { "wrap-ansi": {
"version": "3.0.1", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz",
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==",
"dev": true, "dev": true,
"requires": { "requires": {
"ansi-regex": "^2.0.0" "ansi-styles": "^3.2.0",
"string-width": "^3.0.0",
"strip-ansi": "^5.0.0"
} }
}, },
"which-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz",
"integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=",
"dev": true
},
"y18n": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz",
"integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=",
"dev": true
},
"yargs": { "yargs": {
"version": "7.1.0", "version": "13.3.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz",
"integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=", "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==",
"dev": true, "dev": true,
"requires": { "requires": {
"camelcase": "^3.0.0", "cliui": "^5.0.0",
"cliui": "^3.2.0", "find-up": "^3.0.0",
"decamelize": "^1.1.1", "get-caller-file": "^2.0.1",
"get-caller-file": "^1.0.1",
"os-locale": "^1.4.0",
"read-pkg-up": "^1.0.1",
"require-directory": "^2.1.1", "require-directory": "^2.1.1",
"require-main-filename": "^1.0.1", "require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0", "set-blocking": "^2.0.0",
"string-width": "^1.0.2", "string-width": "^3.0.0",
"which-module": "^1.0.0", "which-module": "^2.0.0",
"y18n": "^3.2.1", "y18n": "^4.0.0",
"yargs-parser": "^5.0.0" "yargs-parser": "^13.1.2"
} }
}, },
"yargs-parser": { "yargs-parser": {
"version": "5.0.0", "version": "13.1.2",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz",
"integrity": "sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=", "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==",
"dev": true, "dev": true,
"requires": { "requires": {
"camelcase": "^3.0.0" "camelcase": "^5.0.0",
"decamelize": "^1.2.0"
} }
} }
} }
@ -13402,6 +13419,12 @@
} }
} }
}, },
"servor": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/servor/-/servor-4.0.2.tgz",
"integrity": "sha512-MlmQ5Ntv4jDYUN060x/KEmN7emvIqKMZ9OkM+nY8Bf2+KkyLmGsTqWLyAN2cZr5oESAcH00UanUyyrlS1LRjFw==",
"dev": true
},
"set-blocking": { "set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@ -14266,14 +14289,24 @@
"requires": { "requires": {
"svg.js": "^2.6.5", "svg.js": "^2.6.5",
"svg.select.js": "^2.1.2" "svg.select.js": "^2.1.2"
},
"dependencies": {
"svg.select.js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz",
"integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==",
"requires": {
"svg.js": "^2.2.5"
}
}
} }
}, },
"svg.select.js": { "svg.select.js": {
"version": "2.1.2", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz", "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz",
"integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==", "integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==",
"requires": { "requires": {
"svg.js": "^2.2.5" "svg.js": "^2.6.5"
} }
}, },
"svgo": { "svgo": {
@ -14877,9 +14910,9 @@
"dev": true "dev": true
}, },
"typescript": { "typescript": {
"version": "3.7.5", "version": "3.9.7",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz",
"integrity": "sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==", "integrity": "sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw==",
"dev": true "dev": true
}, },
"uglify-js": { "uglify-js": {
@ -15193,14 +15226,14 @@
"dev": true "dev": true
}, },
"vue": { "vue": {
"version": "2.6.11", "version": "2.6.12",
"resolved": "https://registry.npmjs.org/vue/-/vue-2.6.11.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.12.tgz",
"integrity": "sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ==" "integrity": "sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg=="
}, },
"vue-apexcharts": { "vue-apexcharts": {
"version": "1.5.2", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/vue-apexcharts/-/vue-apexcharts-1.5.2.tgz", "resolved": "https://registry.npmjs.org/vue-apexcharts/-/vue-apexcharts-1.6.0.tgz",
"integrity": "sha512-m7IIyql4yU6cLTu5RODx3DcdxCekmNRzUh7lEoybq2MXcgabmBPhUn8qgXNx1HucWiMNOdXfwq/L6TfCbKnfMw==" "integrity": "sha512-sT6tuVTLBwfH3TA7azecDNS/W70bmz14ZJI7aE7QIqcG9I6OywyH7x3hcOeY1v1DxttI8Svc5RuYj4Dd+A5F4g=="
}, },
"vue-class-component": { "vue-class-component": {
"version": "6.3.2", "version": "6.3.2",
@ -15262,9 +15295,9 @@
} }
}, },
"vue-router": { "vue-router": {
"version": "3.1.5", "version": "3.4.5",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.1.5.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.4.5.tgz",
"integrity": "sha512-BszkPvhl7I9h334GjckCh7sVFyjTPMMJFJ4Bsrem/Ik+B/9gt5tgrk8k4gGLO4ZpdvciVdg7O41gW4DisQWurg==" "integrity": "sha512-ioRY5QyDpXM9TDjOX6hX79gtaMXSVDDzSlbIlyAmbHNteIL81WIVB2e+jbzV23vzxtoV0krdS2XHm+GxFg+Nxg=="
}, },
"vue-style-loader": { "vue-style-loader": {
"version": "4.1.2", "version": "4.1.2",
@ -15277,9 +15310,9 @@
} }
}, },
"vue-template-compiler": { "vue-template-compiler": {
"version": "2.6.11", "version": "2.6.12",
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.6.11.tgz", "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.6.12.tgz",
"integrity": "sha512-KIq15bvQDrcCjpGjrAhx4mUlyyHfdmTaoNfeoATHLAiWB+MU3cx4lOzMwrnUh9cCxy0Lt1T11hAFY6TQgroUAA==", "integrity": "sha512-OzzZ52zS41YUbkCBfdXShQTe69j1gQDZ9HIX8miuC9C3rBCk9wIRjLiZZLrmX9V+Ftq/YEyv1JaVr5Y/hNtByg==",
"dev": true, "dev": true,
"requires": { "requires": {
"de-indent": "^1.0.2", "de-indent": "^1.0.2",
@ -15301,9 +15334,9 @@
} }
}, },
"vuex": { "vuex": {
"version": "3.1.2", "version": "3.5.1",
"resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.2.tgz", "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.5.1.tgz",
"integrity": "sha512-ha3jNLJqNhhrAemDXcmMJMKf1Zu4sybMPr9KxJIuOpVcsDQlTBYLLladav2U+g1AvdYDG5Gs0xBTb0M5pXXYFQ==" "integrity": "sha512-w7oJzmHQs0FM9LXodfskhw9wgKBiaB+totOdb8sNzbTB2KDCEEwEs29NzBZFh/lmEK1t5tDmM1vtsO7ubG1DFw=="
}, },
"vuex-module-decorators": { "vuex-module-decorators": {
"version": "0.9.11", "version": "0.9.11",

View File

@ -1,41 +1,43 @@
{ {
"name": "personal-measure-web", "name": "personal-measure-web",
"version": "0.8.0", "version": "0.11.0",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "npx servor dist",
"build-prod": "vue-cli-service build --mode production", "build-prod": "vue-cli-service build --mode production",
"build-dev": "vue-cli-service build --mode development", "build-dev": "vue-cli-service build --mode development",
"lint": "vue-cli-service lint", "lint": "vue-cli-service lint",
"test:unit": "vue-cli-service test:unit" "test:unit": "vue-cli-service test:unit"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.27", "@fortawesome/fontawesome-svg-core": "^1.2.31",
"@fortawesome/free-solid-svg-icons": "^5.12.1", "@fortawesome/free-solid-svg-icons": "^5.15.0",
"@fortawesome/vue-fontawesome": "^0.1.9", "@fortawesome/vue-fontawesome": "^0.1.10",
"@types/js-cookie": "^2.2.4", "@types/js-cookie": "^2.2.6",
"@types/jwt-decode": "^2.2.1", "@types/jwt-decode": "^2.2.1",
"@types/lodash.assign": "^4.2.6", "@types/lodash.assign": "^4.2.6",
"@types/lodash.findindex": "^4.6.6", "@types/lodash.findindex": "^4.6.6",
"@types/lodash.merge": "^4.6.6", "@types/lodash.merge": "^4.6.6",
"apexcharts": "^3.15.6", "@types/lodash.omit": "^4.5.6",
"apexcharts": "^3.21.0",
"axios": "^0.18.1", "axios": "^0.18.1",
"js-cookie": "^2.2.1", "js-cookie": "^2.2.1",
"jwt-decode": "^2.2.0", "jwt-decode": "^2.2.0",
"keen-ui": "^1.2.1", "keen-ui": "^1.3.1",
"lodash.assign": "^4.2.0", "lodash.assign": "^4.2.0",
"lodash.findindex": "^4.6.0", "lodash.findindex": "^4.6.0",
"lodash.keyby": "^4.6.0", "lodash.keyby": "^4.6.0",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"moment": "^2.24.0", "lodash.omit": "^4.5.0",
"register-service-worker": "^1.5.2", "moment": "^2.29.0",
"vue": "^2.6.11", "register-service-worker": "^1.7.1",
"vue-apexcharts": "^1.5.2", "vue": "^2.6.12",
"vue-apexcharts": "^1.6.0",
"vue-class-component": "^6.0.0", "vue-class-component": "^6.0.0",
"vue-property-decorator": "^7.0.0", "vue-property-decorator": "^7.0.0",
"vue-router": "^3.1.5", "vue-router": "^3.4.5",
"vuejs-smart-table": "0.0.3", "vuejs-smart-table": "0.0.3",
"vuex": "^3.1.2", "vuex": "^3.5.1",
"vuex-module-decorators": "^0.9.11" "vuex-module-decorators": "^0.9.11"
}, },
"devDependencies": { "devDependencies": {
@ -46,16 +48,17 @@
"@vue/cli-plugin-typescript": "^3.12.1", "@vue/cli-plugin-typescript": "^3.12.1",
"@vue/cli-plugin-unit-jest": "^3.12.1", "@vue/cli-plugin-unit-jest": "^3.12.1",
"@vue/cli-service": "^3.12.1", "@vue/cli-service": "^3.12.1",
"@vue/test-utils": "^1.0.0-beta.31", "@vue/test-utils": "^1.1.0",
"babel-core": "7.0.0-bridge.0", "babel-core": "7.0.0-bridge.0",
"lint-staged": "^8.2.1", "lint-staged": "^8.2.1",
"live-server": "^1.2.1", "live-server": "^1.2.1",
"node-sass": "^4.13.1", "node-sass": "^4.14.1",
"sass-loader": "^7.3.1", "sass-loader": "^7.3.1",
"servor": "^4.0.2",
"ts-jest": "^23.0.0", "ts-jest": "^23.0.0",
"typescript": "^3.7.5", "typescript": "^3.9.7",
"vue-cli-plugin-webpack-bundle-analyzer": "^1.4.0", "vue-cli-plugin-webpack-bundle-analyzer": "^1.4.0",
"vue-template-compiler": "^2.6.11" "vue-template-compiler": "^2.6.12"
}, },
"gitHooks": { "gitHooks": {
"pre-commit": "lint-staged" "pre-commit": "lint-staged"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models'; import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
import moment from 'moment';
import assign from 'lodash.assign'; import assign from 'lodash.assign';
import { library } from '@fortawesome/fontawesome-svg-core'; import { library } from '@fortawesome/fontawesome-svg-core';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons'; import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { byTimestampComparator, formatTS } from '@/util';
library.add(faPencilAlt); library.add(faPencilAlt);
@ -12,8 +12,6 @@ export class SimpleDetails extends Vue {
@Prop() private measure!: Measure<MeasureConfig>; @Prop() private measure!: Measure<MeasureConfig>;
@Prop() private measurements!: Array<Measurement<MeasurementMeta>>; @Prop() private measurements!: Array<Measurement<MeasurementMeta>>;
// private newMeasurement;
private moment = moment;
private chartOptions = { private chartOptions = {
markers: { size: 6 }, markers: { size: 6 },
noData: { text: 'no data', noData: { text: 'no data',
@ -28,7 +26,7 @@ export class SimpleDetails extends Vue {
return [{ return [{
name: this.measure.name, name: this.measure.name,
data: measurementData data: measurementData
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) .sort(byTimestampComparator)
.map((m) => ({ x: m.timestamp.toISOString(), y: m.value })) .map((m) => ({ x: m.timestamp.toISOString(), y: m.value }))
}]; }];
} }
@ -36,7 +34,7 @@ export class SimpleDetails extends Vue {
private get measurementTableData() { private get measurementTableData() {
return (this.measurements || []).map((m) => { return (this.measurements || []).map((m) => {
return assign({}, m, { return assign({}, m, {
tsDisplay: moment(m.timestamp).format('MMM Do, HH:mm'), tsDisplay: formatTS(this.measure, m),
tsSort: m.timestamp.toISOString() tsSort: m.timestamp.toISOString()
}); });
}); });

View File

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

View File

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

View File

@ -5,7 +5,7 @@
{{measure.name}}</router-link></h2> {{measure.name}}</router-link></h2>
<SimpleSummaryGraph v-if="measure.config.type === 'simple'" <SimpleSummaryGraph v-if="measure.config.type === 'simple'"
:measure=measure :measurements=measurements /> :measure=measure :measurements=measurements />
<ListSummary v-if="measure.config.type === 'list'" <TextSummary v-if="measure.config.type === 'text'"
:measure=measure :measurements=measurements /> :measure=measure :measurements=measurements />
</div> </div>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,9 @@
<fieldset> <fieldset>
<div> <div>
<label for=timestamp>Timestamp</label> <label for=timestamp>Timestamp</label>
<input type=datetime-local <input
name=timestamp
type=datetime-local
v-model=value.timestamp v-model=value.timestamp
v-show=editTimestamp v-show=editTimestamp
:disabled=disabled /> :disabled=disabled />

View File

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

View File

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

View File

@ -11,8 +11,6 @@ export class SimpleEntry extends Vue {
@Watch('value', { immediate: true, deep: true }) @Watch('value', { immediate: true, deep: true })
@Emit('input') @Emit('input')
private onMeasurementChanged(newVal: Measurement<MeasurementMeta>, oldVal: Measurement<MeasurementMeta>) { private onMeasurementChanged(newVal: Measurement<MeasurementMeta>, oldVal: Measurement<MeasurementMeta>) {
newVal.extData.measureType = 'simple' as MeasureType;
if (typeof(newVal.value) === 'string' ) { if (typeof(newVal.value) === 'string' ) {
newVal.value = parseInt(newVal.value, 10); newVal.value = parseInt(newVal.value, 10);
} }

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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