Compare commits

..

No commits in common. "main" and "0.7.0" have entirely different histories.
main ... 0.7.0

92 changed files with 2954 additions and 4302 deletions

2
.gitignore vendored
View File

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

View File

@ -5,12 +5,14 @@ build: dist/personal-measure-api.tar.gz dist/personal-measure-web.tar.gz
clean:
-rm -r dist
-rm api/personal_measure_api
-rm -r web/dist
-docker container prune
-docker image prune
update-version:
operations/update-version.sh
dist/personal-measure-api.tar.gz:
-mkdir dist
make -C api personal_measure_api
tar czf dist/personal-measure-api-${VERSION}.tar.gz -C api personal_measure_api
cp dist/personal-measure-api-${VERSION}.tar.gz dist/personal-measure-api.tar.gz
dist/personal-measure-web.tar.gz:
-mkdir dist
@ -18,14 +20,18 @@ dist/personal-measure-web.tar.gz:
tar czf dist/personal-measure-web-${VERSION}.tar.gz -C web/dist .
cp dist/personal-measure-web-${VERSION}.tar.gz dist/personal-measure-web.tar.gz
deploy-api:
make -C api personal_measure_api-image push-image
cd operations/terraform && terraform apply -target module.${TARGET_ENV}_env.aws_ecs_task_definition.pmapi -target module.${TARGET_ENV}_env.aws_ecs_service.pmapi
deploy-api: dist/personal-measure-api.tar.gz
mkdir -p temp-deploy/personal-measure-api-${VERSION}
tar xzf dist/personal-measure-api-${VERSION}.tar.gz -C temp-deploy/personal-measure-api-${VERSION}
-ssh pmapi@pmapi.jdb-labs.com "sudo systemctl stop personal_measure_api.$(TARGET_ENV).service"
scp temp-deploy/personal-measure-api-${VERSION}/personal_measure_api pmapi@pmapi.jdb-labs.com:/home/pmapi/$(TARGET_ENV)/personal_measure_api
ssh pmapi@pmapi.jdb-labs.com "sudo systemctl start personal_measure_api.$(TARGET_ENV).service"
rm -r temp-deploy
deploy-web: dist/personal-measure-web.tar.gz
mkdir -p temp-deploy/personal-measure-web-${VERSION}
tar xzf dist/personal-measure-web-${VERSION}.tar.gz -C temp-deploy/personal-measure-web-${VERSION}
aws s3 sync temp-deploy/personal-measure-web-${VERSION} s3://pm.jdb-software.com/$(TARGET_ENV)/webroot
aws s3 sync temp-deploy/personal-measure-web-${VERSION} s3://pm.jdb-labs.com/$(TARGET_ENV)/webroot
TARGET_ENV=${TARGET_ENV} operations/invalidate-cdn-cache.sh
rm -r temp-deploy

View File

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

View File

@ -1,123 +1,31 @@
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)
# 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).
serve: personal_measure_api start-postgres
./personal_measure_api serve
# Running the API locally in a container
# --------------------------------------
serve-docker: personal_measure_api-image
# Run the API in a docker container. Note that the configuration loaded into
# the Docker container defines very little of the actual configuration as
# environment variables are used in the deployed environments. Accordingly,
# we must specify them explicitly here.
docker run \
-e AUTH_SECRET=$(AUTH_SECRET) \
-e PORT=80 \
-e "DB_CONN_STRING=$(DB_CONN_STRING)" \
-e 'KNOWN_ORIGINS=["https://curl.localhost"]' \
-p 127.0.0.1:$(PORT):80/tcp \
$(ECR_ACCOUNT_URL)/personal_measure_api:$(VERSION)
# Managing Postgres in a local container
# --------------------------------------
#
# This supports local development on this machine. These commands rely on a
# file named `postgres.container.id` to track the existing and ID of the
# local Postgres instance.
postgres.container.id:
# This creates a new local Postegres container and initializes the PM API
# database scheme.
docker run --name postgres-$(DB_NAME) -e POSTGRES_PASSWORD=password -p 5500:5432 -d postgres > postgres.container.id
sleep 5
PGPASSWORD=password psql -p 5500 -U postgres -h localhost -c "CREATE DATABASE $(DB_NAME);"
db_migrate up -c database-local.json
start-postgres: postgres.container.id
# Start the existing local Postgres container
docker start $(PGSQL_CONTAINER_ID)
sleep 1
db_migrate up -c database-local.json
stop-postgres: postgres.container.id
# Stop the existing local Postgres container
docker stop $(PGSQL_CONTAINER_ID)
delete-postgres-container:
# Delete the local Postgres container. Note that this will destroy any data
# in this database instance.
-docker stop $(PGSQL_CONTAINER_ID)
docker container rm $(PGSQL_CONTAINER_ID)
rm postgres.container.id
connect-postgres:
# Connect to the Postgres instance running in the local container
connect:
PGPASSWORD=password psql -p 5500 -U postgres -h localhost ${DB_NAME}
# 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"
personal_measure_api: $(SOURCES)
nimble build

View File

@ -1,30 +0,0 @@
## 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,7 @@
import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils,
times, uuids
from httpcore import HttpMethod
strutils, times, uuids
from unicode import capitalize
import strutils except capitalize
import timeutils
import timeutils except `<`
import ./db, ./configuration, ./models, ./service, ./version
@ -59,29 +57,6 @@ template jsonResp(code: HttpCode, body: string = "", headersToSend: RawHeaders =
body
)
template optionsResp(allowedMethods: seq[HttpMethod]) =
let reqOrigin =
if request.headers.hasKey("Origin"): $(request.headers["Origin"])
else: ""
let corsHeaders =
if ctx.cfg.knownOrigins.contains(reqOrigin):
@{
"Access-Control-Allow-Origin": reqOrigin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": allowedMethods.mapIt($it).join(", "),
"Access-Control-Allow-Headers": "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
}
else: @{:}
halt(
Http200,
corsHeaders,
""
)
template jsonResp(body: string) = jsonResp(Http200, body)
template statusResp(code: HttpCode, details: string = "", headersToSend: RawHeaders = @{:} ) =
@ -121,7 +96,7 @@ proc fromJWT*(ctx: PMApiContext, strTok: string): Session =
## Validate a given JWT and extract the session data.
let jwt = toJWT(strTok)
var secret = ctx.cfg.authSecret
if not jwt.verify(secret, HS256): raiseEx "Unable to verify auth token."
if not jwt.verify(secret): raiseEx "Unable to verify auth token."
jwt.verifyTimeClaims()
# Find the user record (if authenticated)
@ -236,13 +211,9 @@ proc start*(ctx: PMApiContext): void =
routes:
options "/version": optionsResp(@[HttpGet])
get "/version":
jsonResp($(%("personal_measure_api v" & PM_API_VERSION)))
options "/auth-token": optionsResp(@[HttpPost])
post "/auth-token":
try:
@ -254,8 +225,6 @@ proc start*(ctx: PMApiContext): void =
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except: statusResp(Http401, getCurrentExceptionMsg())
options "/change-pwd": optionsResp(@[HttpPost])
post "/change-pwd":
checkAuth()
@ -277,8 +246,6 @@ proc start*(ctx: PMApiContext): void =
error "internal error changing password: " & getCurrentExceptionMsg()
statusResp(Http500)
options "/change-pwd/@userId": optionsResp(@[HttpPost])
post "/change-pwd/@userId":
checkAuth(true)
@ -300,8 +267,6 @@ proc start*(ctx: PMApiContext): void =
error "internal error changing password: " & getCurrentExceptionMsg()
statusResp(Http500)
options "/user": optionsResp(@[HttpGet, HttpPut])
get "/user":
checkAuth()
@ -326,8 +291,6 @@ proc start*(ctx: PMApiContext): void =
error "Could not update user information:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/users": optionsResp(@[HttpGet, HttpPost])
get "/users":
checkAuth(true)
@ -356,8 +319,6 @@ proc start*(ctx: PMApiContext): void =
error "Could not create new user:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/users/@userId": optionsResp(@[HttpGet, HttpDelete])
get "/users/@userId":
checkAuth(true)
@ -378,8 +339,6 @@ proc start*(ctx: PMApiContext): void =
except: statusResp(Http500, getCurrentExceptionMsg())
options "/api-tokens": optionsResp(@[HttpGet, HttpPost])
get "/api-tokens":
checkAuth()
@ -414,8 +373,6 @@ proc start*(ctx: PMApiContext): void =
debug getCurrentExceptionMsg()
statusResp(Http500)
options "/api-tokens/@tokenId": optionsResp(@[HttpGet, HttpDelete])
get "/api-tokens/@tokenId":
checkAuth()
@ -434,10 +391,6 @@ proc start*(ctx: PMApiContext): void =
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: statusResp(Http500)
# Measure
options "/measures": optionsResp(@[HttpGet, HttpPost])
get "/measures":
checkAuth()
@ -482,8 +435,6 @@ proc start*(ctx: PMApiContext): void =
error "unable to create new measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/measures/@slug": optionsResp(@[HttpGet, HttpPost, HttpDelete])
get "/measures/@slug":
checkAuth()
@ -493,37 +444,6 @@ proc start*(ctx: PMApiContext): void =
error "unable to look up a measure by id:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
post "/measures/@slug":
checkAuth()
try:
let jsonBody = parseJson(request.body)
var existingMeasure = ctx.getMeasureForSlug(session.user.id, @"slug")
if not (jsonBody.hasKey("slug") or jsonBody.hasKey("name")):
raiseEx BadRequestError, "body must contain either the 'slug' field (short name), or the 'name' field, or both"
existingMeasure.slug =
if jsonBody.hasKey("slug"): jsonBody["slug"].getStr.nameToSlug
else: jsonBody["name"].getStr.nameToSlug
existingMeasure.name =
if jsonBody.hasKey("name"): jsonBody["name"].getStr
else: jsonBody["slug"].getStr.capitalize
if jsonBody.hasKey("config"): existingMeasure.config = jsonBody["config"]
if jsonBody.hasKey("description"): existingMeasure.description = jsonBody["description"].getStr
jsonResp($(%ctx.db.updateMeasure(existingMeasure)))
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except:
error "unable to update measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
delete "/measures/@slug":
checkAuth()
@ -536,11 +456,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to delete a measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
# Measurements
options "/measurements/@slug": optionsResp(@[HttpGet, HttpPost])
get "/measurements/@slug":
get "/measure/@slug":
checkAuth()
try:
@ -551,7 +467,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to list measurements:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
post "/measurements/@slug":
post "/measure/@slug":
checkAuth()
try:
@ -560,7 +476,7 @@ proc start*(ctx: PMApiContext): void =
let newMeasurement = Measurement(
measureId: measure.id,
value: jsonBody.getOrFail("value").getFloat,
value: jsonBody.getOrFail("value").getInt,
timestamp:
if jsonBody.hasKey("timestamp"): jsonBody["timestamp"].getStr.parseIso8601.utc
else: getTime().utc,
@ -577,9 +493,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to add measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/measurements/@slug/@id": optionsResp(@[HttpGet, HttpPut, HttpDelete])
get "/measurements/@slug/@id":
get "/measure/@slug/@id":
checkAuth()
try:
@ -594,14 +508,14 @@ proc start*(ctx: PMApiContext): void =
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
put "/measurements/@slug/@id":
put "/measure/@slug/@id":
checkAuth()
try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
var measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id"))
let jsonBody = parseJson(request.body)
if jsonBody.hasKey("value"): measurement.value = jsonBody["value"].getFloat
if jsonBody.hasKey("value"): measurement.value = jsonBody["value"].getInt
if jsonBody.hasKey("timestamp"): measurement.timestamp = jsonBody["timestamp"].getStr.parseIso8601
if jsonBody.hasKey("extData"): measurement.extData = jsonBody["extData"]
jsonResp($(%ctx.db.updateMeasurement(measurement)))
@ -614,7 +528,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
delete "/measurements/@slug/@id":
delete "/measure/@slug/@id":
checkAuth()
try:
@ -631,8 +545,6 @@ proc start*(ctx: PMApiContext): void =
error "unable to delete measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/log": optionsResp(@[HttpPost])
post "/log":
checkAuth()
@ -650,8 +562,6 @@ proc start*(ctx: PMApiContext): void =
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except: statusResp(Http500, getCurrentExceptionMsg())
options "/log/batch": optionsResp(@[HttpPost])
post "/log/batch":
checkAuth()

View File

@ -1,4 +1,4 @@
import db_postgres, fiber_orm, sequtils, uuids
import db_postgres, fiber_orm, uuids
import ./models

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +0,0 @@
### 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?).

View File

@ -1,9 +0,0 @@
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

@ -1 +0,0 @@
010

View File

@ -1,8 +0,0 @@
### 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

@ -1,10 +0,0 @@
### 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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +0,0 @@
### 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

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

View File

@ -0,0 +1,33 @@
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

@ -0,0 +1,33 @@
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" {
description = "Name of the S3 bucket to store deployed artifacts, logs, etc."
default = "pm.jdb-software.com"
default = "pm.jdb-labs.com"
}

View File

@ -1,25 +0,0 @@
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

@ -1,75 +0,0 @@
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

@ -1,69 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -6,18 +6,18 @@ data "aws_iam_policy_document" "bucket_access_policy" {
principals {
type = "AWS"
identifiers = [ aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn ]
identifiers = [ "${aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn}" ]
}
}
statement {
actions = [ "s3:ListBucket" ]
effect = "Allow"
resources = [ var.artifact_bucket.arn ]
resources = [ "${var.artifact_bucket.arn}" ]
principals {
type = "AWS"
identifiers = [ aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn ]
identifiers = [ "${aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn}" ]
}
}
}
@ -26,18 +26,22 @@ output "oai_access_policy" {
value = data.aws_iam_policy_document.bucket_access_policy
}
locals {
env_domain_name = "pm${var.environment == "prod" ? "" : "-${var.environment}"}.jdb-labs.com"
}
resource "aws_cloudfront_origin_access_identity" "origin_access_identity" {
comment = "OAI for Personal Measure {$var.environment} environment."
}
resource "aws_cloudfront_distribution" "s3_distribution" {
origin {
domain_name = var.artifact_bucket.bucket_regional_domain_name
domain_name = "${var.artifact_bucket.bucket_regional_domain_name}"
origin_id = "S3-PersonalMeasure-${var.environment}"
origin_path = "/${var.environment}/webroot"
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.origin_access_identity.cloudfront_access_identity_path
origin_access_identity = "${aws_cloudfront_origin_access_identity.origin_access_identity.cloudfront_access_identity_path}"
}
}
@ -48,11 +52,11 @@ resource "aws_cloudfront_distribution" "s3_distribution" {
logging_config {
include_cookies = false
bucket = var.artifact_bucket.bucket_domain_name
bucket = "${var.artifact_bucket.bucket_domain_name}"
prefix = "${var.environment}/logs/cloudfront"
}
aliases = [local.app_domain_name]
aliases = ["${local.env_domain_name}"]
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
@ -88,11 +92,11 @@ resource "aws_cloudfront_distribution" "s3_distribution" {
}
}
tags = {
Environment = local.environment_name
Environment = "${var.environment}"
}
viewer_certificate {
acm_certificate_arn = data.terraform_remote_state.jdbsoft.outputs.aws_acm_certificate_jdbsoft_us_east_1.arn
acm_certificate_arn = "${var.cloudfront_ssl_certificate_arn}"
ssl_support_method = "sni-only"
}
}

View File

@ -8,27 +8,6 @@ variable "artifact_bucket" {
description = "The aws_s3_bucket object representing the artifact bucket where deployed artifacts, logs, etc. live."
}
variable "ecr_repo" {
description = "ECR repository information."
}
locals {
environment_name = "PersonalMeasure-${var.environment}"
app_domain_name = "pm${var.environment == "prod" ? "" : "-${var.environment}"}.jdb-software.com"
api_domain_name = "pmapi${var.environment == "prod" ? "" : "-${var.environment}"}.jdb-software.com"
}
data "external" "git_describe" {
program = ["sh", "-c", "git describe | xargs printf '{\"version\": \"%s\"}'"]
}
data "terraform_remote_state" "jdbsoft" {
backend = "s3"
config = {
bucket = "operations.jdb-software.com"
region = "us-west-2"
key = "terraform/operations.tfstate"
dynamodb_table = "terraform-state-lock.jdb-software.com"
}
variable "cloudfront_ssl_certificate_arn" {
description = "ARN of the managed SSL certificate to use for this environment."
}

View File

@ -1,8 +0,0 @@
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,24 +3,40 @@ provider "aws" {
}
resource "aws_s3_bucket" "personal_measure" {
bucket = var.app_root_url
bucket = "${var.app_root_url}"
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" {
source = "./deployed_env"
environment = "dev"
environment = "dev"
artifact_bucket = aws_s3_bucket.personal_measure
ecr_repo = aws_ecr_repository.personal_measure_api
cloudfront_ssl_certificate_arn = "arn:aws:acm:us-east-1:063932952339:certificate/48fe3ce0-4700-4eaa-b433-bb634f47934c"
}
module "prod_env" {
source = "./deployed_env"
environment = "prod"
environment = "prod"
artifact_bucket = aws_s3_bucket.personal_measure
ecr_repo = aws_ecr_repository.personal_measure_api
cloudfront_ssl_certificate_arn = "arn:aws:acm:us-east-1:063932952339:certificate/48fe3ce0-4700-4eaa-b433-bb634f47934c"
}
data "aws_iam_policy_document" "cloudfront_access_policy" {
@ -29,6 +45,6 @@ data "aws_iam_policy_document" "cloudfront_access_policy" {
}
resource "aws_s3_bucket_policy" "personal_measure" {
bucket = aws_s3_bucket.personal_measure.id
policy = data.aws_iam_policy_document.cloudfront_access_policy.json
bucket = "${aws_s3_bucket.personal_measure.id}"
policy = "${data.aws_iam_policy_document.cloudfront_access_policy.json}"
}

View File

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

View File

@ -47,13 +47,9 @@ printf ">> Updating /api/src/main/nim/personal_measure_apipkg/version.nim with P
printf "sed -i \"s/%s/%s/\" api/src/main/nim/personal_measure_apipkg/version.nim" "$lastVersion" "$newVersion"
sed -i "s/${lastVersion}/${newVersion}/" api/src/main/nim/personal_measure_apipkg/version.nim
printf ">> Updating /api/personal_measure_api.nimble with version = \"%s\"" "$newVersion"
printf "sed -i \"s/%s/%s/\" api/personal_measure_api.nimble" "$lastVersion" "$newVersion"
sed -i "s/${lastVersion}/${newVersion}/" api/personal_measure_api.nimble
printf ">> Committing new version.\n"
printf "git add web/package.json web/package-lock.json api/src/main/nim/personal_measure_apipkg/version.nim"
git add web/package.json web/package-lock.json api/src/main/nim/personal_measure_apipkg/version.nim api/personal_measure_api.nimble
git add web/package.json web/package-lock.json api/src/main/nim/personal_measure_apipkg/version.nim
printf "git commit -m \"Update package version to %s\"\n" "$newVersion"
git commit -m "Update package version to ${newVersion}"

View File

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

View File

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

5725
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -2,40 +2,19 @@
<fieldset>
<div>
<label for=measureType>Type</label>
<span v-if=measureExists>{{value.type}}</span>
<select
:disabled=disabled
name=measureType
v-if="!measureExists"
v-model=value.type>
<option value=simple>Simple</option>
<option value=text>Text</option>
<option value=list>List</option>
</select>
</div>
<div>
<label for=measureIsVisible>Show by default.</label>
<input type=checkbox v-model=value.isVisible :disabled=disabled />
</div>
<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 />
<!--<ListMeasureConfigForm :config=config v-show="config.type === 'list'"/>-->
</fieldset>
</template>
<script lang=ts src=./measure-config-form.ts></script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +0,0 @@
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

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

View File

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

View File

@ -1,13 +0,0 @@
<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

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

View File

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

View File

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

View File

@ -1,39 +0,0 @@
@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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,25 +0,0 @@
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

@ -1,25 +0,0 @@
<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

@ -1,52 +0,0 @@
<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,26 +5,7 @@
<h1>{{measure.name}}</h1>
<h2>{{measure.description}}</h2>
</div>
<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>
<router-link :to="'/new/measurement/' + measure.slug" class=btn-action>Add Measurement</router-link>
</div>
<MeasureDetails :measure=measure :measurements=measurements />
</div>
@ -35,4 +16,4 @@
</div>
</template>
<script lang="ts" src="./measure.ts"></script>
<style scoped lang="scss" src="./measure.scss"></style>
<style lang="scss" src="./measure.scss"></style>

View File

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

View File

@ -1,38 +0,0 @@
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

@ -1,56 +0,0 @@
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 +1,3 @@
@import '~@/styles/vars';

View File

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

View File

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

View File

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

View File

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