5 Commits
0.9.0 ... 0.6.0

82 changed files with 3965 additions and 3802 deletions

View File

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

3
.env.prod Normal file
View File

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

6
.gitignore vendored
View File

@ -26,9 +26,3 @@ yarn-error.log*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Terrform files
.terraform/
# API Testing Files
api/temp/

View File

@ -1,13 +1,8 @@
VERSION:=$(shell git describe --always) VERSION:=$(shell git describe --always)
TARGET_ENV ?= dev TARGET_ENV?=dev
build: dist/personal-measure-api.tar.gz dist/personal-measure-web.tar.gz 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
dist/personal-measure-api.tar.gz: dist/personal-measure-api.tar.gz:
-mkdir dist -mkdir dist
make -C api personal_measure_api make -C api personal_measure_api
@ -16,7 +11,7 @@ dist/personal-measure-api.tar.gz:
dist/personal-measure-web.tar.gz: dist/personal-measure-web.tar.gz:
-mkdir dist -mkdir dist
TARGET_ENV=$(TARGET_ENV) make -C web build (TARGET_ENV=$(TARGET_ENV) ./set-env.sh make -C web build)
tar czf dist/personal-measure-web-${VERSION}.tar.gz -C web/dist . tar czf dist/personal-measure-web-${VERSION}.tar.gz -C web/dist .
cp dist/personal-measure-web-${VERSION}.tar.gz dist/personal-measure-web.tar.gz cp dist/personal-measure-web-${VERSION}.tar.gz dist/personal-measure-web.tar.gz
@ -36,3 +31,8 @@ deploy-web: dist/personal-measure-web.tar.gz
rm -r temp-deploy rm -r temp-deploy
deploy: deploy-api deploy-web deploy: deploy-api deploy-web
clean:
-rm -r dist
-rm api/personal_measure_api
-rm -r web/dist

View File

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

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.9.0" version = PM_API_VERSION
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "JDB\'s Personal Measures API" description = "JDB\'s Personal Measures API"
license = "MIT" license = "MIT"
@ -14,8 +14,7 @@ skipExt = @["nim"]
# Dependencies # Dependencies
requires @["nim >= 0.19.4", "bcrypt", "docopt >= 0.6.8", "isaac >= 0.1.3", requires @["nim >= 0.19.4", "bcrypt", "docopt >= 0.6.8", "isaac >= 0.1.3",
"jester >= 0.4.3", "jwt", "tempfile", "uuids >= 0.1.10" ] "jester >= 0.4.1", "jwt", "tempfile", "uuids >= 0.1.10" ]
requires "https://git.jdb-labs.com/jdb/nim-cli-utils.git >= 0.6.3" requires "https://git.jdb-labs.com/jdb/nim-cli-utils.git >= 0.6.3"
requires "https://git.jdb-labs.com/jdb/nim-time-utils.git >= 0.5.2" 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.3.0"

View File

@ -47,7 +47,7 @@ proc loadConfig*(args: Table[string, docopt.Value] = initTable[string, docopt.Va
port: parseInt(cfg.getVal("port", "8080")), port: parseInt(cfg.getVal("port", "8080")),
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: toSeq(knownOriginsArray).mapIt(it.getStr))
proc initContext(args: Table[string, docopt.Value]): PMApiContext = proc initContext(args: Table[string, docopt.Value]): PMApiContext =
var cfg: PMApiConfig var cfg: PMApiConfig

View File

@ -1,8 +1,7 @@
import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils, import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils,
times, uuids strutils, times, uuids
from unicode import capitalize from unicode import capitalize
import strutils except capitalize import timeutils except `<`
import timeutils
import ./db, ./configuration, ./models, ./service, ./version import ./db, ./configuration, ./models, ./service, ./version
@ -21,7 +20,7 @@ proc newSession*(user: User): Session =
template halt(code: HttpCode, template halt(code: HttpCode,
headers: RawHeaders, headers: RawHeaders,
content: string) = content: string): typed =
## Immediately replies with the specified request. This means any further ## Immediately replies with the specified request. This means any further
## code will not be executed after calling this template in the current ## code will not be executed after calling this template in the current
## route. ## route.
@ -70,6 +69,11 @@ template statusResp(code: HttpCode, details: string = "", headersToSend: RawHead
}), }),
headersToSend) headersToSend)
template execptionResp(ex: ref Exception, details: string = ""): void =
when not defined(release): debug ex.getStackTrace()
error details & ":\n" & ex.msg
statusResp(Http500)
# internal JSON parsing utils # internal JSON parsing utils
proc getIfExists(n: JsonNode, key: string): JsonNode = proc getIfExists(n: JsonNode, key: string): JsonNode =
## convenience method to get a key from a JObject or return null ## convenience method to get a key from a JObject or return null
@ -392,8 +396,6 @@ proc start*(ctx: PMApiContext): void =
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg()) except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: statusResp(Http500) except: statusResp(Http500)
# Measure
get "/measures": get "/measures":
checkAuth() checkAuth()
@ -447,37 +449,6 @@ 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()
@ -490,8 +461,7 @@ 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)
# Measurements get "/measure/@slug":
get "/measurements/@slug":
checkAuth() checkAuth()
try: try:
@ -502,7 +472,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 "/measurements/@slug": post "/measure/@slug":
checkAuth() checkAuth()
try: try:
@ -511,7 +481,7 @@ proc start*(ctx: PMApiContext): void =
let newMeasurement = Measurement( let newMeasurement = Measurement(
measureId: measure.id, measureId: measure.id,
value: jsonBody.getOrFail("value").getFloat, value: jsonBody.getOrFail("value").getInt,
timestamp: timestamp:
if jsonBody.hasKey("timestamp"): jsonBody["timestamp"].getStr.parseIso8601.utc if jsonBody.hasKey("timestamp"): jsonBody["timestamp"].getStr.parseIso8601.utc
else: getTime().utc, else: getTime().utc,
@ -528,7 +498,7 @@ 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 "/measurements/@slug/@id": get "/measure/@slug/@id":
checkAuth() checkAuth()
try: try:
@ -543,14 +513,14 @@ 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 "/measurements/@slug/@id": put "/measure/@slug/@id":
checkAuth() checkAuth()
try: try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug") let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
var measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id")) var measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id"))
let jsonBody = parseJson(request.body) let jsonBody = parseJson(request.body)
if jsonBody.hasKey("value"): measurement.value = jsonBody["value"].getFloat if jsonBody.hasKey("value"): measurement.value = jsonBody["value"].getInt
if jsonBody.hasKey("timestamp"): measurement.timestamp = jsonBody["timestamp"].getStr.parseIso8601 if jsonBody.hasKey("timestamp"): measurement.timestamp = jsonBody["timestamp"].getStr.parseIso8601
if jsonBody.hasKey("extData"): measurement.extData = jsonBody["extData"] if jsonBody.hasKey("extData"): measurement.extData = jsonBody["extData"]
jsonResp($(%ctx.db.updateMeasurement(measurement))) jsonResp($(%ctx.db.updateMeasurement(measurement)))
@ -563,7 +533,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 "/measurements/@slug/@id": delete "/measure/@slug/@id":
checkAuth() checkAuth()
try: try:

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -5,7 +5,15 @@ variable "aws_region" {
default = "us-west-2" # Oregon default = "us-west-2" # Oregon
} }
variable "app_root_url" { variable "deploy_bucket_name" {
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-labs.com"
} }
#### Provider Configuration
provider "aws" {
region = var.aws_region
}

View File

@ -1,28 +1,8 @@
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "personal_measure" { resource "aws_s3_bucket" "personal_measure" {
bucket = "${var.app_root_url}" bucket = "${var.deploy_bucket_name}"
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"

View File

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

View File

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

View File

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

View File

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

8
set-env.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
if [[ -z $TARGET_ENV ]]; then
echo "TARGET_ENV variable is not set."
exit 1
fi
export $(grep -v '^#' .env.$TARGET_ENV | xargs )
"$@"

View File

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

1
web/.env.production Symbolic link
View File

@ -0,0 +1 @@
../.env.prod

View File

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

5428
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,63 +1,61 @@
{ {
"name": "personal-measure-web", "name": "personal-measure-web",
"version": "0.9.0", "version": "0.6.0",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build-prod": "vue-cli-service build --mode production", "build": "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.15",
"@fortawesome/free-solid-svg-icons": "^5.12.1", "@fortawesome/free-solid-svg-icons": "^5.7.2",
"@fortawesome/vue-fontawesome": "^0.1.9", "@fortawesome/vue-fontawesome": "^0.1.5",
"@types/js-cookie": "^2.2.4", "@types/js-cookie": "^2.2.1",
"@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.5",
"@types/lodash.omit": "^4.5.6", "apexcharts": "^3.6.5",
"apexcharts": "^3.15.6",
"axios": "^0.18.1", "axios": "^0.18.1",
"js-cookie": "^2.2.1", "js-cookie": "^2.2.0",
"jwt-decode": "^2.2.0", "jwt-decode": "^2.2.0",
"keen-ui": "^1.2.1", "keen-ui": "^1.1.2",
"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",
"lodash.omit": "^4.5.0",
"moment": "^2.24.0", "moment": "^2.24.0",
"register-service-worker": "^1.5.2", "register-service-worker": "^1.5.2",
"vue": "^2.6.11", "vue": "^2.6.6",
"vue-apexcharts": "^1.5.2", "vue-apexcharts": "^1.3.2",
"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.0.1",
"vuejs-smart-table": "0.0.3", "vuejs-smart-table": "0.0.3",
"vuex": "^3.1.2", "vuex": "^3.0.1",
"vuex-module-decorators": "^0.9.11" "vuex-module-decorators": "^0.9.8"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^23.1.4", "@types/jest": "^23.1.4",
"@types/lodash.keyby": "^4.6.6", "@types/lodash.keyby": "^4.6.6",
"@vue/cli-plugin-babel": "^3.12.1", "@vue/cli-plugin-babel": "^3.4.0",
"@vue/cli-plugin-pwa": "^3.12.1", "@vue/cli-plugin-pwa": "^3.4.0",
"@vue/cli-plugin-typescript": "^3.12.1", "@vue/cli-plugin-typescript": "^3.4.0",
"@vue/cli-plugin-unit-jest": "^3.12.1", "@vue/cli-plugin-unit-jest": "^3.7.0",
"@vue/cli-service": "^3.12.1", "@vue/cli-service": "^3.5.3",
"@vue/test-utils": "^1.0.0-beta.31", "@vue/test-utils": "^1.0.0-beta.20",
"babel-core": "7.0.0-bridge.0", "babel-core": "7.0.0-bridge.0",
"lint-staged": "^8.2.1", "lint-staged": "^8.1.0",
"live-server": "^1.2.1", "live-server": "^1.2.1",
"node-sass": "^4.13.1", "node-sass": "^4.12.0",
"sass-loader": "^7.3.1", "sass-loader": "^7.1.0",
"ts-jest": "^23.0.0", "ts-jest": "^23.0.0",
"typescript": "^3.7.5", "typescript": "^3.0.0",
"vue-cli-plugin-webpack-bundle-analyzer": "^1.4.0", "vue-cli-plugin-webpack-bundle-analyzer": "^1.3.0",
"vue-template-compiler": "^2.6.11" "vue-template-compiler": "^2.5.21"
}, },
"gitHooks": { "gitHooks": {
"pre-commit": "lint-staged" "pre-commit": "lint-staged"

View File

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

View File

@ -2,40 +2,19 @@
<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=text>Text</option> <option value=list>List</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>
<div> <!--<ListMeasureConfigForm :config=config v-show="config.type === 'list'"/>-->
<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

@ -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 { 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

@ -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> <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,6 +15,7 @@
</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

@ -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 { 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: { components: { SimpleDetails }
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,11 +12,12 @@ export class SimpleDetails extends Vue {
@Prop() private measure!: Measure<MeasureConfig>; @Prop() private measure!: Measure<MeasureConfig>;
@Prop() private measurements!: Array<Measurement<MeasurementMeta>>; @Prop() private measurements!: Array<Measurement<MeasurementMeta>>;
// private newMeasurement;
private moment = moment;
private chartOptions = { private chartOptions = {
markers: { size: 6 },
noData: { text: 'no data', noData: { text: 'no data',
style: { fontSize: '18px' } }, style: { fontSize: '18px' } },
stroke: { curve: 'straight' }, stroke: { curve: 'smooth' },
xaxis: { type: 'datetime' } xaxis: { type: 'datetime' }
}; };
@ -26,7 +27,7 @@ export class SimpleDetails extends Vue {
return [{ return [{
name: this.measure.name, name: this.measure.name,
data: measurementData data: measurementData
.sort(byTimestampComparator) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
.map((m) => ({ x: m.timestamp.toISOString(), y: m.value })) .map((m) => ({ x: m.timestamp.toISOString(), y: m.value }))
}]; }];
} }
@ -34,7 +35,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: formatTS(this.measure, m), tsDisplay: moment(m.timestamp).format('MMM Do, HH:mm'),
tsSort: m.timestamp.toISOString() 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> <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 <h2><router-link
:to="'/measures/' + measure.slug"> :to="'/measures/' + measure.slug">
{{measure.name}}</router-link></h2> {{measure.name}}</router-link></h2>
<SimpleSummaryGraph v-if="measure.config.type === 'simple'" <SimpleSummaryGraph v-if="measure.config.type === 'simple'"
:measure=measure :measurements=measurements /> :measure=measure :measurements=measurements />
<TextSummary v-if="measure.config.type === 'text'" <ListSummary v-if="measure.config.type === 'list'"
:measure=measure :measurements=measurements /> :measure=measure :measurements=measurements />
</div> </div>
</template> </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 { 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 TextSummary from './TextSummary.vue'; import ListSummary from './ListSummary.vue';
import SimpleSummaryGraph from './SimpleSummaryGraph.vue'; import SimpleSummaryGraph from './SimpleSummaryGraph.vue';
@Component({ @Component({
components: { components: {
TextSummary, ListSummary,
SimpleSummaryGraph SimpleSummaryGraph
} }
}) })

View File

@ -9,14 +9,14 @@ export class SimpleSummaryGraph extends Vue {
private chartOptions = { private chartOptions = {
chart: { sparkline: { enabled: true } }, chart: { sparkline: { enabled: true } },
grid: { padding: { top: 20 }}, grid: { padding: { top: 20 }},
stroke: { curve: 'straight' }, stroke: { curve: 'smooth' },
noData: { text: 'no data', noData: { text: 'no data',
style: { fontSize: '18px' } }, style: { fontSize: '18px' } },
xaxis: { type: 'datetime' } xaxis: { type: 'datetime' }
}; };
private get measurementData(): ApexAxisChartSeries { private get measurementData(): ApexAxisChartSeries {
const measurementData = this.measurements.slice() || []; let measurementData = this.measurements.slice() || [];
return [{ return [{
name: this.measure.name, 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> <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,9 +2,7 @@
<fieldset> <fieldset>
<div> <div>
<label for=timestamp>Timestamp</label> <label for=timestamp>Timestamp</label>
<input <input type=datetime-local
name=timestamp
type=datetime-local
v-model=value.timestamp v-model=value.timestamp
v-show=editTimestamp v-show=editTimestamp
:disabled=disabled /> :disabled=disabled />
@ -14,7 +12,7 @@
</div> </div>
<div> <div>
<label for=measurementValue>{{measure.name}}</label> <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> </div>
</fieldset> </fieldset>
</template> </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 { 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: { components: { SimpleEntry }
SimpleEntry,
TextEntry
}
}) })
export class MeasurementEntry extends Vue { export class MeasurementEntry extends Vue {
@Prop() private measure!: Measure<MeasureConfig>; @Prop() private measure!: Measure<MeasureConfig>;

View File

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

View File

@ -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 { export interface ApiToken {
id: string; id: string;
@ -17,10 +17,9 @@ export interface LoginSubmit {
export interface MeasureConfig { export interface MeasureConfig {
type: MeasureType; type: MeasureType;
isVisible: boolean; isVisible: boolean;
timestampDisplayFormat: string;
} }
export interface TextMeasureConfig extends MeasureConfig { export interface ListMeasureConfig extends MeasureConfig {
showTimestamp: boolean; showTimestamp: boolean;
} }
@ -34,9 +33,10 @@ export interface Measure<C extends MeasureConfig> {
} }
export interface MeasurementMeta { export interface MeasurementMeta {
measureType: MeasureType;
} }
export interface TextMeasurementMeta extends MeasurementMeta { export interface ListMeasurementMeta extends MeasurementMeta {
entry: string; entry: string;
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -6,9 +6,7 @@ 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';
@ -30,29 +28,13 @@ export class MeasureStoreModule extends VuexModule {
} }
@Action({ rawError: true }) @Action({ rawError: true })
public async createMeasure<T extends MeasureConfig>(m: Measure<T>) { public async createMeasure(m: Measure<MeasureConfig>) {
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 = assign({}, this.measures, {[measure.slug]: measure}); this.measures[measure.slug] = measure;
}
@Mutation private DELETE_MEASURE<T extends MeasureConfig>(measure: Measure<T>) {
this.measures = assign({}, omit(this.measures, measure.slug));
} }
} }

View File

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

View File

@ -1,9 +1,7 @@
@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;
@ -15,27 +13,14 @@ button,
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, &:focus { &:hover {
background-color: lighten($color2, 20%); 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> <h1>{{measure.name}}</h1>
<h2>{{measure.description}}</h2> <h2>{{measure.description}}</h2>
</div> </div>
<div class=actions> <router-link :to="'/new/measurement/' + measure.slug" class=btn-action>Add Measurement</router-link>
<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>
@ -35,4 +16,4 @@
</div> </div>
</template> </template>
<script lang="ts" src="./measure.ts"></script> <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> <div class=measure-list>
<MeasureSummary <MeasureSummary
v-for="(measure, slug) in measures" v-for="(measure, slug) in measures"
v-bind:key="measure.id" v-show="measure.slug.startsWith(filter)"
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

@ -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'; @import '~@/styles/vars';

View File

@ -1,14 +1,8 @@
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,7 +5,6 @@ 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);
@ -20,8 +19,7 @@ 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: '',
@ -30,12 +28,19 @@ export class NewMeasure extends Vue {
}; };
private get slugFromName() { 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() { private async createMeasure() {
if (!this.measure.slug) { if (!this.measure.slug) {
this.measure.slug = slugify(this.measure.name); this.measure.slug = this.slugify(this.measure.name);
} }
this.waiting = true; this.waiting = true;

View File

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

View File

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

View File

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