Compare commits

..
2 Commits
Author SHA1 Message Date
jaytux a90b2ebeca Rename Label->Version, add version meta field 2026-08-09 20:18:11 +02:00
jaytux 60b1a69bc6 Added input, hardware fields to data 2026-08-09 17:25:18 +02:00
16 changed files with 331 additions and 191 deletions
+1 -1
View File
@@ -12,4 +12,4 @@ repositories {
mavenCentral() mavenCentral()
} }
version = "0.1.0-dev" version = "0.2.0-dev"
@@ -98,7 +98,7 @@ object CLI {
sealed interface IProjectIdentification sealed interface IProjectIdentification
sealed interface ILabelIdentification sealed interface ILabelIdentification
data class ProjectName(val user: String, val project: String) : IProjectIdentification data class ProjectName(val user: String, val project: String) : IProjectIdentification
data class LabelName(val name: String): ILabelIdentification data class VersionName(val name: String): ILabelIdentification
data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification
sealed interface IData<T> { sealed interface IData<T> {
@@ -168,22 +168,23 @@ object CLI {
} }
@Suppress("unused") @Suppress("unused")
class AddLabel : CliktCommand(name = "add-label") { class AddVersion : CliktCommand(name = "add-version") {
val parent by requireObject<Project>() val parent by requireObject<Project>()
val name by option("--name", help = "Set the label's name (*)") val name by option("--name", help = "Set the version's name (*)")
val color by option("--color", help = "Set the label's color (*)").check("Color must be specified in RGB-hex-format (#ABCDEF)") { val color by option("--color", help = "Set the version's color (*)").check("Color must be specified in RGB-hex-format (#ABCDEF)") {
it.length == 7 && it[0] == '#' && it.substring(1, it.length).all { c -> c.isDigit() || c in "ABCDEF" } it.length == 7 && it[0] == '#' && it.substring(1, it.length).all { c -> c.isDigit() || c in "ABCDEF" }
} }
val meta by option("--meta", help = "Add metadata to the version (*)")
override fun run() = ProjectHandlers.newLabel(name, color, parent.finder) override fun run() = ProjectHandlers.newVersion(name, color, parent.finder, meta)
} }
@Suppress("unused") @Suppress("unused")
class AddData : CliktCommand(name = "add-data") { class AddData : CliktCommand(name = "add-data") {
val parent by requireObject<Project>() val parent by requireObject<Project>()
val label by mutuallyExclusiveOptions<ILabelIdentification>( val version by mutuallyExclusiveOptions<ILabelIdentification>(
option("--label-id", help = "Set the label by UUID.").convert { ID(Uuid.parse(it)) }, option("--version-id", help = "Set the version by UUID.").convert { ID(Uuid.parse(it)) },
option("--label", help = "Set the label by name.").convert { LabelName(it) } option("--version", help = "Set the version by name.").convert { VersionName(it) }
).single() ).single()
val warmup by mutuallyExclusiveOptions<IData<Float>>( val warmup by mutuallyExclusiveOptions<IData<Float>>(
option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) }, option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
@@ -201,8 +202,10 @@ object CLI {
option("--min", "--m", "--minutes", help = "Set the time unit to minutes").flag().convert { TimeUnit.MINUTES }, option("--min", "--m", "--minutes", help = "Set the time unit to minutes").flag().convert { TimeUnit.MINUTES },
option("--h", "--hour", help = "Set the time unit to hours").flag().convert { TimeUnit.HOURS } option("--h", "--hour", help = "Set the time unit to hours").flag().convert { TimeUnit.HOURS }
).single() ).single()
val input by option("--input", help = "Set the input used to benchmark")
val hardware by option("--hardware", "--hw", help = "Set the hardware on which the benchmark was run")
override fun run() = ProjectHandlers.newData(parent.finder, label, warmup, measurement, unit) override fun run() = ProjectHandlers.newData(parent.finder, version, warmup, measurement, unit, input, hardware)
} }
} }
} }
@@ -1,16 +1,11 @@
package com.jaytux.phoebench.clients.cli package com.jaytux.phoebench.clients.cli
import com.github.ajalt.clikt.parameters.options.convert
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.jaytux.phoebench.clients.cli.CLI.Commands.Project.ProjectName import com.jaytux.phoebench.clients.cli.CLI.Commands.Project.ProjectName
import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.EmptyRequest import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.EntryRequest import com.jaytux.phoebench.common.EntryRequest
import com.jaytux.phoebench.common.ErrorResponse import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.LabelRequest import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.LabelResponse
import com.jaytux.phoebench.common.ProjectRequest import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.TimeUnit import com.jaytux.phoebench.common.TimeUnit
@@ -19,9 +14,6 @@ import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.fold import com.jaytux.phoebench.common.fold
import com.jaytux.phoebench.common.map import com.jaytux.phoebench.common.map
import com.jaytux.phoebench.common.value import com.jaytux.phoebench.common.value
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.toLocalDateTime
import java.nio.file.OpenOption
import kotlin.io.path.Path import kotlin.io.path.Path
import kotlin.io.path.exists import kotlin.io.path.exists
import kotlin.io.path.inputStream import kotlin.io.path.inputStream
@@ -100,7 +92,7 @@ object ProjectHandlers {
} }
} }
fun newLabel(name: String?, color: String?, project: CLI.Commands.Project.IProjectIdentification?) { fun newVersion(name: String?, color: String?, project: CLI.Commands.Project.IProjectIdentification?, version: String?) {
val id = ensureProjectIdentification(project) val id = ensureProjectIdentification(project)
val useName = name.maybePrompt("label name") { it } val useName = name.maybePrompt("label name") { it }
val useColor = color.maybePrompt("color") { val useColor = color.maybePrompt("color") {
@@ -111,10 +103,11 @@ object ProjectHandlers {
exitProcess(-1) exitProcess(-1)
} }
} }
val useVersion = version.maybePrompt("version") { it }
tryAuthenticated { tryAuthenticated {
id.toId().bind { projectId -> id.toId().bind { projectId ->
Client.callRoute(Routes.Label.new, LabelRequest(useName, useColor, projectId)) Client.callRoute(Routes.Version.new, VersionRequest(useName, useColor, useVersion, projectId))
} }
}.fold({ }.fold({
System.err.println("Could not create label: ${it.msg}") System.err.println("Could not create label: ${it.msg}")
@@ -139,11 +132,11 @@ object ProjectHandlers {
label: CLI.Commands.Project.ILabelIdentification?, label: CLI.Commands.Project.ILabelIdentification?,
warmup: CLI.Commands.Project.IData<Float>?, warmup: CLI.Commands.Project.IData<Float>?,
measurement: CLI.Commands.Project.IData<Float>?, measurement: CLI.Commands.Project.IData<Float>?,
unit: TimeUnit? unit: TimeUnit?, input: String?, hardware: String?
) { ) {
val projectId = ensureProjectIdentification(project) val projectId = ensureProjectIdentification(project)
val labelId = label.maybePrompt("label name") { val versionId = label.maybePrompt("version name") {
CLI.Commands.Project.LabelName(it) CLI.Commands.Project.VersionName(it)
} }
val warmupData = warmup.ensure("warmup").toList() val warmupData = warmup.ensure("warmup").toList()
val measureData = measurement.ensure("measurements").toList() val measureData = measurement.ensure("measurements").toList()
@@ -162,20 +155,22 @@ object ProjectHandlers {
} }
} }
} }
val useInput = input.maybePrompt("benchmark input") { it }
val useHardware = hardware.maybePrompt("hardware description") { it }
tryAuthenticated { tryAuthenticated {
projectId.toId().bind { pId -> projectId.toId().bind { pId ->
when(labelId) { when(versionId) {
is CLI.Commands.Project.ID -> labelId.id.value() is CLI.Commands.Project.ID -> versionId.id.value()
is CLI.Commands.Project.LabelName -> Client.callRoute(Routes.Project.get, pId).bind { is CLI.Commands.Project.VersionName -> Client.callRoute(Routes.Project.get, pId).bind {
it.usedLabels.firstOrNull { l -> l.name == labelId.name }?.id?.value() it.usedLabels.firstOrNull { l -> l.name == versionId.name }?.id?.value()
?: ErrorResponse("Label ${labelId.name} does not exist in this project.").error() ?: ErrorResponse("Label ${versionId.name} does not exist in this project.").error()
} }
}.map { pId to it } }.map { pId to it }
}.bind { (pId, lId) -> }.bind { (pId, vId) ->
Client.callRoute(Routes.Entry.new, EntryRequest( Client.callRoute(Routes.Entry.new, EntryRequest(
lId, Clock.System.now(), pId, vId, Clock.System.now(), pId,
warmupData, measureData, timeUnit warmupData, measureData, timeUnit, useInput, useHardware
)) ))
} }
} }
@@ -76,7 +76,7 @@ class Client private constructor(private val _auth: AuthProvider) {
println("Call to ${route.pattern} [$ctr] was cancelled") println("Call to ${route.pattern} [$ctr] was cancelled")
ErrorResponse(COROUTINE_CANCELLED).error() ErrorResponse(COROUTINE_CANCELLED).error()
} catch (e: Exception) { } catch (e: Exception) {
println("Call to ${route.pattern} [$ctr] ran into an exception") println("Call to ${route.pattern} [$ctr] ran into an exception: ${e.message}")
ErrorResponse(e.message ?: "Unknown error.").error() ErrorResponse(e.message ?: "Unknown error.").error()
} }
} }
@@ -8,15 +8,13 @@ import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.EntryRequest import com.jaytux.phoebench.common.EntryRequest
import com.jaytux.phoebench.common.EntryResponse import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.ErrorResponse import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.LabelRequest import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.LabelResponse import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.PartialEntryRequest
import com.jaytux.phoebench.common.PartialLabelRequest
import com.jaytux.phoebench.common.PartialProjectRequest import com.jaytux.phoebench.common.PartialProjectRequest
import com.jaytux.phoebench.common.PartialVersionRequest
import com.jaytux.phoebench.common.ProjectResponse import com.jaytux.phoebench.common.ProjectResponse
import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.TimeUnit import com.jaytux.phoebench.common.TimeUnit
import kotlinx.datetime.TimeZone
import kotlin.time.Instant import kotlin.time.Instant
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -25,12 +23,12 @@ interface IProjectRepo {
suspend fun update(name: String? = null, isPublic: Boolean? = null): Either<ErrorResponse, Unit> suspend fun update(name: String? = null, isPublic: Boolean? = null): Either<ErrorResponse, Unit>
suspend fun delete(): Either<ErrorResponse, Unit> suspend fun delete(): Either<ErrorResponse, Unit>
suspend fun newLabel(name: String, color: Color): Either<ErrorResponse, LabelResponse> suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse>
suspend fun updateLabel(id: Uuid, name: String? = null, color: Color? = null): Either<ErrorResponse, Unit> suspend fun updateVersion(id: Uuid, name: String? = null, color: Color? = null, meta: String? = null): Either<ErrorResponse, Unit>
suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit> suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit>
suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>, suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
unit: TimeUnit): Either<ErrorResponse, EntryResponse> unit: TimeUnit, input: String, hardware: String): Either<ErrorResponse, EntryResponse>
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
companion object { companion object {
@@ -44,19 +42,19 @@ interface IProjectRepo {
override suspend fun delete(): Either<ErrorResponse, Unit> = override suspend fun delete(): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.delete, _projectId).ignoreValue() _client.callRoute(Routes.Project.delete, _projectId).ignoreValue()
override suspend fun newLabel(name: String, color: Color): Either<ErrorResponse, LabelResponse> = override suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse> =
_client.callRoute(Routes.Label.new, LabelRequest(name, color.hexString(), _projectId)) _client.callRoute(Routes.Version.new, VersionRequest(name, color.hexString(), meta, _projectId))
override suspend fun updateLabel(id: Uuid, name: String?, color: Color?): Either<ErrorResponse, Unit> = override suspend fun updateVersion(id: Uuid, name: String?, color: Color?, meta: String?): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Label.update, id to PartialLabelRequest(name, color?.hexString())).ignoreValue() _client.callRoute(Routes.Version.update, id to PartialVersionRequest(name, color?.hexString(), meta)).ignoreValue()
override suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit> = override suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Label.delete, id).ignoreValue() _client.callRoute(Routes.Version.delete, id).ignoreValue()
override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>,
measurements: List<Float>, unit: TimeUnit measurements: List<Float>, unit: TimeUnit, input: String, hardware: String
): Either<ErrorResponse, EntryResponse> = ): Either<ErrorResponse, EntryResponse> =
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit)) _client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit, input, hardware))
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> = override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Entry.delete, id).ignoreValue() _client.callRoute(Routes.Entry.delete, id).ignoreValue()
@@ -11,13 +11,11 @@ import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
import com.jaytux.phoebench.clients.hexString import com.jaytux.phoebench.clients.hexString
import com.jaytux.phoebench.clients.immutable import com.jaytux.phoebench.clients.immutable
import com.jaytux.phoebench.clients.insort import com.jaytux.phoebench.clients.insort
import com.jaytux.phoebench.clients.systemTz
import com.jaytux.phoebench.clients.withScope import com.jaytux.phoebench.clients.withScope
import com.jaytux.phoebench.common.EntryResponse import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.LabelResponse import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.ProjectEvent import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.TimeUnit import com.jaytux.phoebench.common.TimeUnit
import com.jaytux.phoebench.common.foldSuspend
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -32,7 +30,7 @@ class ProjectVM(
private val _sseRepo: ISSERepo = ISSERepo.default(_client), private val _sseRepo: ISSERepo = ISSERepo.default(_client),
private val _forceBack: () -> Unit private val _forceBack: () -> Unit
) : ViewModel() { ) : ViewModel() {
data class Label(val id: Uuid, val name: String, val colorStr: String, val uiColor: Color = parseColor(colorStr)) { data class Version(val id: Uuid, val name: String, val colorStr: String, val meta: String, val uiColor: Color = parseColor(colorStr)) {
companion object { companion object {
private val _snack by lazy { SnackProvider.get() } private val _snack by lazy { SnackProvider.get() }
private val _errColor = Color(red = 252, green = 20, blue = 182) private val _errColor = Color(red = 252, green = 20, blue = 182)
@@ -50,18 +48,18 @@ class ProjectVM(
return Color(red = r, green = g, blue = b) return Color(red = r, green = g, blue = b)
} }
fun fromResponse(it: LabelResponse) = Label(it.id, it.name, it.color) fun fromResponse(it: VersionResponse) = Version(it.id, it.name, it.color, it.meta)
val invalid = Label(Uuid.fromLongs(0L, 0L), "<invalid>", _errColor.hexString(), _errColor) val invalid = Version(Uuid.fromLongs(0L, 0L), "<invalid>", _errColor.hexString(), "", _errColor)
} }
} }
data class Entry(val id: Uuid, val label: Label, val timeStamp: Instant, val warmups: List<Float>, data class Entry(val id: Uuid, val label: Version, val timeStamp: Instant, val warmups: List<Float>,
val measurements: List<Float>, val nativeUnit: TimeUnit) { val measurements: List<Float>, val nativeUnit: TimeUnit, val input: String, val hardware: String) {
companion object { companion object {
fun fromResponse(it: EntryResponse, map: Map<Uuid, Label>) = Entry( fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>) = Entry(
it.id, map[it.labelId] ?: Label.invalid, it.timestamp, it.id, map[it.labelId] ?: Version.invalid, it.timestamp,
it.warmups, it.measurements, it.unit it.warmups, it.measurements, it.unit, it.input, it.hardware
) )
} }
} }
@@ -70,15 +68,19 @@ class ProjectVM(
private val _owner = mutableStateOf<String?>(null) private val _owner = mutableStateOf<String?>(null)
private val _public = mutableStateOf(false) private val _public = mutableStateOf(false)
private val _editable = mutableStateOf(false) private val _editable = mutableStateOf(false)
private val _labels = mutableStateOf(mapOf<Uuid, Label>()) private val _labels = mutableStateOf(mapOf<Uuid, Version>())
private val _entries = mutableStateOf(listOf<Entry>()) private val _entries = mutableStateOf(listOf<Entry>())
private val _inputs = mutableStateOf(setOf<String>())
private val _hardware = mutableStateOf(setOf<String>())
val name = _name.immutable() val name = _name.immutable()
val owner = _owner.immutable() val owner = _owner.immutable()
val public = _public.immutable() val public = _public.immutable()
val editable = _editable.immutable() val editable = _editable.immutable()
val labels = _labels.immutable() val versions = _labels.immutable()
val entries = _entries.immutable() val entries = _entries.immutable()
val inputs = _inputs.immutable()
val hardware = _hardware.immutable()
private var _job: Job? = null private var _job: Job? = null
@@ -100,8 +102,16 @@ class ProjectVM(
_owner.value = it.owner.name _owner.value = it.owner.name
_public.value = it.isPublic _public.value = it.isPublic
_editable.value = it.isEditable _editable.value = it.isEditable
_labels.value = it.usedLabels.associate { l -> l.id to Label.fromResponse(l) } _labels.value = it.usedLabels.associate { l -> l.id to Version.fromResponse(l) }
_entries.value = it.entries.map { e -> Entry.fromResponse(e, _labels.value) } val localInputs = mutableSetOf<String>()
val localHardware = mutableSetOf<String>()
_entries.value = it.entries.map { e ->
localInputs += e.input
localHardware += e.hardware
Entry.fromResponse(e, _labels.value)
}
_inputs.value = localInputs
_hardware.value = localHardware
} }
} }
} }
@@ -110,10 +120,14 @@ class ProjectVM(
when(event) { when(event) {
ProjectEvent.Deleted -> back() ProjectEvent.Deleted -> back()
is ProjectEvent.EntryDeleted -> _entries.value = _entries.value.filter { it.id != event.id } is ProjectEvent.EntryDeleted -> _entries.value = _entries.value.filter { it.id != event.id }
is ProjectEvent.LabelChanged -> _labels.value += (event.label.id to Label.fromResponse(event.label)) is ProjectEvent.VersionChanged -> _labels.value += (event.label.id to Version.fromResponse(event.label))
is ProjectEvent.LabelDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id } is ProjectEvent.VersionDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id }
is ProjectEvent.NewEntry -> _entries.value = _entries.value.insort(Entry.fromResponse(event.entry, _labels.value), Entry::timeStamp) is ProjectEvent.NewEntry -> {
is ProjectEvent.NewLabel -> _labels.value += (event.label.id to Label.fromResponse(event.label)) _entries.value = _entries.value.insort(Entry.fromResponse(event.entry, _labels.value), Entry::timeStamp)
_inputs.value += event.entry.input
_hardware.value += event.entry.hardware
}
is ProjectEvent.NewVersion -> _labels.value += (event.label.id to Version.fromResponse(event.label))
is ProjectEvent.Updated -> { is ProjectEvent.Updated -> {
_name.value = event.changes.name _name.value = event.changes.name
_owner.value = event.changes.owner.name _owner.value = event.changes.owner.name
@@ -124,7 +138,9 @@ class ProjectVM(
fun update(name: String?, isPublic: Boolean?) { fun update(name: String?, isPublic: Boolean?) {
withScope { withScope {
_repo.update(name, isPublic).snackOr { refresh() } _repo.update(name, isPublic).snackOr {
if(_job == null) refresh()
}
} }
} }
@@ -139,19 +155,21 @@ class ProjectVM(
_forceBack() _forceBack()
} }
fun mkLabel(name: String, color: Color) { fun mkLabel(name: String, color: Color, meta: String) {
withScope { withScope {
_repo.newLabel(name, color).snackOr { _repo.newVersion(name, color, meta).snackOr {
_labels.value += it.id to Label(it.id, it.name, it.color) if(_job == null)
_labels.value += it.id to Version(it.id, it.name, it.color, it.meta)
} }
} }
} }
fun updateLabel(id: Uuid, name: String?, color: Color?) { fun updateLabel(id: Uuid, name: String?, color: Color?, meta: String?) {
withScope { withScope {
val old = _labels.value[id] ?: return@withScope val old = _labels.value[id] ?: return@withScope
_repo.updateLabel(id, name, color).snackOr { _repo.updateVersion(id, name, color).snackOr {
_labels.value += id to Label(id, name ?: old.name, color?.hexString() ?: old.colorStr, color ?: old.uiColor) if(_job == null)
_labels.value += id to Version(id, name ?: old.name, color?.hexString() ?: old.colorStr, meta ?: old.meta, color ?: old.uiColor)
} }
} }
} }
@@ -159,15 +177,20 @@ class ProjectVM(
fun deleteLabel(id: Uuid) { fun deleteLabel(id: Uuid) {
withScope { withScope {
_repo.deleteLabel(id).snackOr { _repo.deleteLabel(id).snackOr {
if(_job == null)
_labels.value -= id _labels.value -= id
} }
} }
} }
fun mkEntry(label: Label, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit) { fun mkEntry(label: Version, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit, input: String, hardware: String) {
withScope { withScope {
_repo.newEntry(label.id, Clock.System.now(), warmups, measurements, unit).snackOr { _repo.newEntry(label.id, Clock.System.now(), warmups, measurements, unit, input, hardware).snackOr {
if(_job == null) {
_entries.value += Entry.fromResponse(it, _labels.value) _entries.value += Entry.fromResponse(it, _labels.value)
_inputs.value += input
_hardware.value += hardware
}
} }
} }
} }
@@ -175,6 +198,7 @@ class ProjectVM(
fun deleteEntry(id: Uuid) { fun deleteEntry(id: Uuid) {
withScope { withScope {
_repo.deleteEntry(id).snackOr { _repo.deleteEntry(id).snackOr {
if(_job == null)
_entries.value = _entries.value.filter { it.id != id } _entries.value = _entries.value.filter { it.id != id }
} }
} }
@@ -13,6 +13,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.backhandler.BackHandler
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
@@ -24,9 +25,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.composables.icons.lucide.* import com.composables.icons.lucide.*
import com.jaytux.phoebench.clients.darken
import com.jaytux.phoebench.clients.data.ProjectVM import com.jaytux.phoebench.clients.data.ProjectVM
import com.jaytux.phoebench.clients.data.mutableStateSetFrom import com.jaytux.phoebench.clients.data.mutableStateSetFrom
import com.jaytux.phoebench.clients.data.mutableStateSetOf
import com.jaytux.phoebench.clients.dualLerp import com.jaytux.phoebench.clients.dualLerp
import com.jaytux.phoebench.clients.fmt import com.jaytux.phoebench.clients.fmt
import com.jaytux.phoebench.clients.fmtRange import com.jaytux.phoebench.clients.fmtRange
@@ -48,8 +49,6 @@ import io.github.koalaplot.core.xygraph.AxisContent
import io.github.koalaplot.core.xygraph.DefaultPoint import io.github.koalaplot.core.xygraph.DefaultPoint
import io.github.koalaplot.core.xygraph.XYGraph import io.github.koalaplot.core.xygraph.XYGraph
import io.github.koalaplot.core.xygraph.autoScaleRange import io.github.koalaplot.core.xygraph.autoScaleRange
import io.github.koalaplot.core.xygraph.autoScaleXRange
import io.github.koalaplot.core.xygraph.autoScaleYRange
import io.github.koalaplot.core.xygraph.rememberAxisStyle import io.github.koalaplot.core.xygraph.rememberAxisStyle
import io.github.koalaplot.core.xygraph.rememberFloatLinearAxisModel import io.github.koalaplot.core.xygraph.rememberFloatLinearAxisModel
import io.github.koalaplot.core.xygraph.rememberGridStyle import io.github.koalaplot.core.xygraph.rememberGridStyle
@@ -66,7 +65,7 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
val owner by vm.owner val owner by vm.owner
val public by vm.public val public by vm.public
val editable by vm.editable val editable by vm.editable
val labels by vm.labels val labels by vm.versions
var editing by remember { mutableStateOf(false) } var editing by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(false) } var deleting by remember { mutableStateOf(false) }
@@ -148,16 +147,19 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
vm.delete() vm.delete()
} }
if(addingLabel) AddLabelDialog({ addingLabel = false; addOpen = false }) { name, color -> if(addingLabel) AddVersionDialog({ addingLabel = false; addOpen = false }) { name, color, meta ->
vm.mkLabel(name, color) vm.mkLabel(name, color, meta)
addOpen = false addOpen = false
} }
if(addingData) AddEntryDialog(labels, val usedInputs by vm.inputs
val usedHardware by vm.hardware
if(addingData) AddEntryDialog(labels, usedInputs, usedHardware,
onCancel = { addingData = false; addOpen = false }, onCancel = { addingData = false; addOpen = false },
onAddLbl = { name, lbl -> vm.mkLabel(name, lbl) } onAddLbl = { name, lbl, meta -> vm.mkLabel(name, lbl, meta) }
) { label, warmups, measurements, unit -> ) { label, warmups, measurements, unit, input, hardware ->
vm.mkEntry(label, warmups, measurements, unit) vm.mkEntry(label, warmups, measurements, unit, input, hardware)
addOpen = false addOpen = false
} }
} }
@@ -205,48 +207,67 @@ fun ConfirmDeleteProjectDialog(name: String, onCancel: () -> Unit, onDelete: ()
} }
@Composable @Composable
fun AddLabelDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color) -> Unit) { fun AddVersionDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color, meta: String) -> Unit) {
Dialog(onDismissRequest = onCancel) { Dialog(onDismissRequest = onCancel) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var color by remember { mutableStateOf(randomColor()) } var color by remember { mutableStateOf(randomColor()) }
var meta by remember { mutableStateOf("") }
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) { Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) { Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
Text("Create label", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) Text("Create version", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") }) OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
KolorPicker(color, { color = it }, alphaTrackVisible = false, modifier = Modifier.aspectRatio(1f)) KolorPicker(color, { color = it }, alphaTrackVisible = false, modifier = Modifier.aspectRatio(1f))
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
OutlinedTextField(meta, { meta = it }, Modifier.fillMaxWidth(), label = { Text("Additional information") })
Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, { CancelConfirmXRow(onCancel, {
onAdd(name.trim(), color) onAdd(name.trim(), color, meta.trim())
}, confirmText = "Create", canConfirm = name.trim().isNotBlank()) }, confirmText = "Create", canConfirm = name.trim().isNotBlank() && meta.trim().isNotBlank())
} }
} }
} }
} }
@Composable @Composable
fun QuickLabel(lbl: ProjectVM.Label) { fun QuickVersion(lbl: ProjectVM.Version, hollow: Boolean = false) {
Row(Modifier.height(IntrinsicSize.Min).padding(vertical = 3.dp), verticalAlignment = Alignment.Bottom) { Row(Modifier.height(IntrinsicSize.Min).padding(vertical = 3.dp), verticalAlignment = Alignment.Bottom) {
Box(Modifier.fillMaxHeight().aspectRatio(1f).background(lbl.uiColor)) {} Box(Modifier.fillMaxHeight().aspectRatio(1f).background(lbl.uiColor)) {
if(hollow) {
Box(Modifier.fillMaxHeight(0.75f).aspectRatio(1f).align(Alignment.Center).background(Color.Gray.copy(alpha = 0.5f)))
}
}
Spacer(Modifier.width(15.dp)) Spacer(Modifier.width(15.dp))
Column {
Row {
Text(lbl.name) Text(lbl.name)
Text(" ${lbl.colorStr}", style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(alpha = 0.75f)) Text(" ${lbl.colorStr}", Modifier.align(Alignment.Bottom),
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(alpha = 0.75f),
)
}
Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(0.75f))
}
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AddEntryDialog( fun AddEntryDialog(
labels: Map<Uuid, ProjectVM.Label>, labels: Map<Uuid, ProjectVM.Version>, usedInputs: Set<String>, usedHardware: Set<String>,
onCancel: () -> Unit, onAddLbl: (name: String, color: Color) -> Unit, onCancel: () -> Unit, onAddLbl: (name: String, color: Color, meta: String) -> Unit,
onAdd: (label: ProjectVM.Label, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit) -> Unit onAdd: (label: ProjectVM.Version, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit, input: String, hardware: String) -> Unit
) { ) {
Dialog(onDismissRequest = onCancel) { Dialog(onDismissRequest = onCancel) {
var label by remember { mutableStateOf<ProjectVM.Label?>(null) } var label by remember { mutableStateOf<ProjectVM.Version?>(null) }
var warmups by remember { mutableStateOf("") } var warmups by remember { mutableStateOf("") }
var measurements by remember { mutableStateOf("") } var measurements by remember { mutableStateOf("") }
var unit by remember { mutableStateOf(TimeUnit.SECONDS) } var unit by remember { mutableStateOf(TimeUnit.SECONDS) }
var input by remember { mutableStateOf("") }
var hardware by remember { mutableStateOf("") }
var addingLabel by remember { mutableStateOf(false) } var addingLabel by remember { mutableStateOf(false) }
var warmupParsed by remember { mutableStateOf<Either<String, List<Float>>>(listOf<Float>().value()) } var warmupParsed by remember { mutableStateOf<Either<String, List<Float>>>(listOf<Float>().value()) }
@@ -281,12 +302,12 @@ fun AddEntryDialog(
tonalElevation = 2.dp tonalElevation = 2.dp
) { ) {
Box(Modifier.fillMaxWidth().clickable { dropDownOpen = true }.padding(8.dp)) { Box(Modifier.fillMaxWidth().clickable { dropDownOpen = true }.padding(8.dp)) {
label?.let { QuickLabel(it) } ?: Text("Select label...", fontStyle = FontStyle.Italic) label?.let { QuickVersion(it) } ?: Text("Select version...", fontStyle = FontStyle.Italic)
} }
} }
DropdownMenu(dropDownOpen, { dropDownOpen = false }) { DropdownMenu(dropDownOpen, { dropDownOpen = false }) {
labels.values.forEach { lbl -> labels.values.forEach { lbl ->
DropdownMenuItem({ QuickLabel(lbl) }, { label = lbl; dropDownOpen = false }) DropdownMenuItem({ QuickVersion(lbl) }, { label = lbl; dropDownOpen = false })
} }
HorizontalDivider(Modifier.height(1.dp)) HorizontalDivider(Modifier.height(1.dp))
@@ -308,6 +329,39 @@ fun AddEntryDialog(
} }
} }
Row {
var inputFocused by remember { mutableStateOf(false) }
var hardwareFocused by remember { mutableStateOf(false) }
Box(Modifier.weight(0.5f)) {
ExposedDropdownMenuBox(inputFocused, {}) {
OutlinedTextField(input, { input = it },
Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable).onFocusChanged { inputFocused = it.isFocused },
label = { Text("Input") }
)
ExposedDropdownMenu(inputFocused, {}) {
usedInputs.filter { input.lowercase() in it.lowercase() }.sorted().forEach { inp ->
DropdownMenuItem(text = { Text(inp) }, onClick = { input = inp })
}
}
}
}
Spacer(Modifier.width(5.dp))
Box(Modifier.weight(0.5f)) {
ExposedDropdownMenuBox(hardwareFocused, { inputFocused = false }) {
OutlinedTextField(hardware, { hardware = it },
Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable).onFocusChanged { hardwareFocused = it.isFocused },
label = { Text("Hardware") }
)
ExposedDropdownMenu(hardwareFocused, { hardwareFocused = false }) {
usedHardware.filter { hardware.lowercase() in it.lowercase() }.sorted().forEach { hw ->
DropdownMenuItem(text = { Text(hw) }, onClick = { hardware = hw })
}
}
}
}
}
Spacer(Modifier.height(7.dp)) Spacer(Modifier.height(7.dp))
Text("Data (separate data points by commas)") Text("Data (separate data points by commas)")
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(5.dp))
@@ -326,19 +380,19 @@ fun AddEntryDialog(
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(5.dp))
CancelConfirmXRow(onCancel, { CancelConfirmXRow(onCancel, {
onAdd(label!!, warmupParsed.asValue()!!, measureParsed.asValue()!!, unit) onAdd(label!!, warmupParsed.asValue()!!, measureParsed.asValue()!!, unit, input, hardware)
}, confirmText = "Create", canConfirm = label != null && warmupParsed.isValue() && measureParsed.isValue()) }, confirmText = "Create", canConfirm = label != null && warmupParsed.isValue() && measureParsed.isValue() && input.isNotBlank() && hardware.isNotBlank())
} }
} }
if(addingLabel) AddLabelDialog({ addingLabel = false }, onAddLbl) if(addingLabel) AddVersionDialog({ addingLabel = false }, onAddLbl)
} }
} }
@Composable @Composable
fun ProjectPlotArea(vm: ProjectVM){ fun ProjectPlotArea(vm: ProjectVM){
val entries by vm.entries val entries by vm.entries
val labels by vm.labels val versions by vm.versions
val editable by vm.editable val editable by vm.editable
var displayWarmups by remember { mutableStateOf(false) } var displayWarmups by remember { mutableStateOf(false) }
@@ -353,20 +407,36 @@ fun ProjectPlotArea(vm: ProjectVM){
var timeFilter by remember { mutableStateOf(0f..1f) } var timeFilter by remember { mutableStateOf(0f..1f) }
var timeFilterString by remember { mutableStateOf("") } var timeFilterString by remember { mutableStateOf("") }
val labelFilter = remember(labels) { mutableStateSetFrom(labels.values) } val versionFilter = remember(versions) { mutableStateSetFrom(versions.values) }
val labelFilterKey by labelFilter.revision val labelVersionFilter by versionFilter.revision
LaunchedEffect(entries, displayWarmups, currentUnit, timeFilter, timeMin, timeMax, labelFilterKey) { val allInputs by vm.inputs
val allHardware by vm.hardware
val enabledInputs = remember { mutableStateSetOf<String>() }
val enabledHardware = remember { mutableStateSetOf<String>() }
var filterInput by remember { mutableStateOf<String?>(null) }
var filterHardware by remember { mutableStateOf<String?>(null) }
LaunchedEffect(entries, displayWarmups, currentUnit, timeFilter, timeMin, timeMax, labelVersionFilter, filterInput, filterHardware) {
var maxX = 0f var maxX = 0f
var minY = 0f var minY = 0f
var maxY = 0f var maxY = 0f
val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive) val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
enabledInputs.clear()
enabledHardware.clear()
renderableEntries = entries.mapNotNull { entry -> renderableEntries = entries.mapNotNull { entry ->
val use = if(displayWarmups) entry.warmups else entry.measurements val use = if(displayWarmups) entry.warmups else entry.measurements
if(entry.label !in versionFilter) return@mapNotNull null
if(!(entry.timeStamp inRange timeRange)) return@mapNotNull null
if(filterInput != null && entry.input != filterInput) return@mapNotNull null
if(filterHardware != null && entry.hardware != filterHardware) return@mapNotNull null
maxX = maxOf(maxX, use.size.toFloat()) maxX = maxOf(maxX, use.size.toFloat())
if(entry.label !in labelFilter) return@mapNotNull null enabledInputs.add(entry.input)
if(!(entry.timeStamp inRange timeRange)) return@mapNotNull null enabledHardware.add(entry.hardware)
use.mapIndexed { idx, it -> use.mapIndexed { idx, it ->
val converted = entry.nativeUnit.convertTo(currentUnit, it) val converted = entry.nativeUnit.convertTo(currentUnit, it)
@@ -482,23 +552,34 @@ fun ProjectPlotArea(vm: ProjectVM){
Spacer(Modifier.width(20.dp)) Spacer(Modifier.width(20.dp))
Box(Modifier.weight(0.33f).fillMaxHeight()) { Row(Modifier.weight(0.33f).fillMaxHeight()) {
Column { Column(Modifier.weight(0.5f)) {
Text("Labels", style = MaterialTheme.typography.headlineSmall) Text("Versions", style = MaterialTheme.typography.headlineSmall)
Text("Click a version to toggle its visibility.", fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
if(labels.isEmpty()) { if(versions.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
Text("No labels yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) Text("No versions yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
} }
} }
else { else {
LazyColumn(Modifier.padding(start = 5.dp)) { LazyColumn(Modifier.padding(start = 5.dp)) {
items(labels.toList().sortedBy { it.second.name }) { (_, lbl) -> items(versions.toList().sortedBy { it.second.name }) { (_, lbl) ->
Box(Modifier.fillMaxWidth().clickable { labelFilter.toggle(lbl) }) { Box(Modifier.fillMaxWidth().clickable {
if(versionFilter.size == versions.size) {
versionFilter.clear()
versionFilter.add(lbl)
}
else {
versionFilter.toggle(lbl)
if(versionFilter.isEmpty()) versionFilter.addAll(versions.values)
}
}) {
Box { Box {
QuickLabel(lbl) QuickVersion(lbl, lbl !in versionFilter)
if(lbl !in labelFilter) { if(lbl !in versionFilter) {
Box(Modifier.matchParentSize()) { Box(Modifier.matchParentSize()) {
HorizontalDivider( HorizontalDivider(
Modifier.fillMaxWidth().align(Alignment.Center), Modifier.fillMaxWidth().align(Alignment.Center),
@@ -513,6 +594,48 @@ fun ProjectPlotArea(vm: ProjectVM){
} }
} }
} }
Spacer(Modifier.width(15.dp))
LazyColumn(Modifier.weight(0.5f)) {
item { Text("Inputs", style = MaterialTheme.typography.headlineSmall) }
item {
Text("Click an input to set or clear the filter.", fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall)
}
item { Spacer(Modifier.height(10.dp)) }
items((allInputs intersect enabledInputs.toSet()).toList()) {
Row(Modifier.fillMaxWidth().clickable { filterInput = if(filterInput == null) it else null }) {
Text(it, Modifier.padding(start = 5.dp))
}
}
items((allInputs - enabledInputs.toSet()).toList()) {
Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f))
}
item { Spacer(Modifier.height(10.dp)) }
item { HorizontalDivider(Modifier.height(1.dp)) }
item { Spacer(Modifier.height(10.dp)) }
item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) }
item {
Text("Click an item to set or clear the filter.", fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall)
}
item { Spacer(Modifier.height(10.dp)) }
items((allHardware intersect enabledHardware.toSet()).toList()) {
Row(Modifier.fillMaxWidth().clickable { filterHardware = if(filterHardware == null) it else null }) {
Text(it, Modifier.padding(start = 5.dp))
}
}
items((allHardware - enabledHardware.toSet()).toList()) {
Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f))
}
}
} }
} }
} }
@@ -43,13 +43,13 @@ sealed class ProjectEvent {
data class Updated(val changes: HomeResponse.ProjectSummary) : ProjectEvent() data class Updated(val changes: HomeResponse.ProjectSummary) : ProjectEvent()
@Serializable @Serializable
data class NewLabel(val label: LabelResponse) : ProjectEvent() data class NewVersion(val label: VersionResponse) : ProjectEvent()
@Serializable @Serializable
data class LabelChanged(val label: LabelResponse) : ProjectEvent() data class VersionChanged(val label: VersionResponse) : ProjectEvent()
@Serializable @Serializable
data class LabelDeleted(val id: Uuid) : ProjectEvent() data class VersionDeleted(val id: Uuid) : ProjectEvent()
@Serializable @Serializable
data class NewEntry(val entry: EntryResponse) : ProjectEvent() data class NewEntry(val entry: EntryResponse) : ProjectEvent()
@@ -1,6 +1,5 @@
package com.jaytux.phoebench.common package com.jaytux.phoebench.common
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.Instant import kotlin.time.Instant
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -23,11 +22,11 @@ data class RefreshRequest(val refreshToken: Uuid)
data class ProjectRequest(val name: String, var isPublic: Boolean) data class ProjectRequest(val name: String, var isPublic: Boolean)
@Serializable @ToPartialize @Serializable @ToPartialize
data class LabelRequest(val name: String, val color: String, val projectId: Uuid) data class VersionRequest(val name: String, val color: String, val meta: String, val projectId: Uuid)
@Serializable @ToPartialize @Serializable @ToPartialize
data class EntryRequest(val label: Uuid, val timestamp: Instant, val projectId: Uuid, val warmups: List<Float>, data class EntryRequest(val version: Uuid, val timestamp: Instant, val projectId: Uuid, val warmups: List<Float>,
val measurements: List<Float>, val unit: TimeUnit) val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
@Serializable @Serializable
data class LogoutRequest(val refresh: Uuid) data class LogoutRequest(val refresh: Uuid)
@@ -1,14 +1,6 @@
package com.jaytux.phoebench.common package com.jaytux.phoebench.common
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlin.time.Instant import kotlin.time.Instant
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -47,14 +39,14 @@ data class HomeResponse(val username: String, val isAdmin: Boolean, val projectL
@Serializable @Serializable
data class ProjectResponse(val id: Uuid, val name: String, val owner: NamedID, val isPublic: Boolean, val isEditable: Boolean, data class ProjectResponse(val id: Uuid, val name: String, val owner: NamedID, val isPublic: Boolean, val isEditable: Boolean,
val usedLabels: List<LabelResponse>, val entries: List<EntryResponse>) val usedLabels: List<VersionResponse>, val entries: List<EntryResponse>)
@Serializable @Serializable
data class LabelResponse(val id: Uuid, val name: String, val color: String) data class VersionResponse(val id: Uuid, val name: String, val color: String, val meta: String)
@Serializable @Serializable
data class EntryResponse(val id: Uuid, val labelId: Uuid, val timestamp: Instant, val warmups: List<Float>, data class EntryResponse(val id: Uuid, val labelId: Uuid, val timestamp: Instant, val warmups: List<Float>,
val measurements: List<Float>, val unit: TimeUnit) val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
@Serializable @Serializable
data class HandshakeResponse(val version: String = ProtocolVersion.VERSION) { data class HandshakeResponse(val version: String = ProtocolVersion.VERSION) {
@@ -30,10 +30,10 @@ object Routes {
val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH) val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH)
} }
object Label { object Version {
val new = ApiRoute.post<LabelRequest, LabelResponse>("/label", Elevation.AUTH) val new = ApiRoute.post<VersionRequest, VersionResponse>("/version", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialLabelRequest>("/label", Elevation.AUTH) val update = ApiRoute.patchUuidNoRes<PartialVersionRequest>("/version", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/label", Elevation.AUTH) val delete = ApiRoute.deleteUuidNoRes("/version", Elevation.AUTH)
} }
object Entry { object Entry {
@@ -129,9 +129,9 @@ fun Application.module() {
patchAuth(Routes.Project.update, ProjectHandler::updateProject) patchAuth(Routes.Project.update, ProjectHandler::updateProject)
deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject) deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject)
postAuth(Routes.Label.new, ProjectHandler::createLabel) postAuth(Routes.Version.new, ProjectHandler::createVersion)
patchAuth(Routes.Label.update, ProjectHandler::updateLabel) patchAuth(Routes.Version.update, ProjectHandler::updateVersion)
deleteAuth(Routes.Label.delete, ProjectHandler::deleteLabel) deleteAuth(Routes.Version.delete, ProjectHandler::deleteVersion)
postAuth(Routes.Entry.new, ProjectHandler::createEntry) postAuth(Routes.Entry.new, ProjectHandler::createEntry)
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry) deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
@@ -1,7 +1,6 @@
package com.jaytux.phoebench.server.db package com.jaytux.phoebench.server.db
import com.jaytux.phoebench.server.DotEnv import com.jaytux.phoebench.server.DotEnv
import io.github.cdimascio.dotenv.Dotenv
import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
@@ -16,9 +15,9 @@ object DB {
) )
transaction { transaction {
SchemaUtils.create(Users, Invites, RefreshTokens, Projects, Labels, Entries) SchemaUtils.create(Users, Invites, RefreshTokens, Projects, Versions, Entries)
val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Labels, Entries) val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Versions, Entries)
try { try {
migration.forEach { migration.forEach {
exec(it) exec(it)
@@ -42,30 +42,33 @@ class Project(id: EntityID<Uuid>) : Entity<Uuid>(id) {
var isPublic by Projects.isPublic var isPublic by Projects.isPublic
var owner by User referencedOn Projects.ownerId var owner by User referencedOn Projects.ownerId
val labels by Label referrersOn Labels.projectId val labels by Version referrersOn Versions.projectId
val entries by Entry referrersOn Entries.projectId val entries by Entry referrersOn Entries.projectId
} }
class Label(id: EntityID<Uuid>) : Entity<Uuid>(id) { class Version(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, Label>(Labels) companion object : EntityClass<Uuid, Version>(Versions)
var label by Labels.label var label by Versions.label
var color by Labels.color var color by Versions.color
var projectId by Labels.projectId var meta by Versions.meta
var projectId by Versions.projectId
var project by Project referencedOn Labels.projectId var project by Project referencedOn Versions.projectId
} }
class Entry(id: EntityID<Uuid>) : Entity<Uuid>(id) { class Entry(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, Entry>(Entries) companion object : EntityClass<Uuid, Entry>(Entries)
var projectId by Entries.projectId var projectId by Entries.projectId
var labelId by Entries.labelId var versionId by Entries.versionId
var timestamp by Entries.timestamp var timestamp by Entries.timestamp
var warmups by Entries.warmups var warmups by Entries.warmups
var measurements by Entries.measurements var measurements by Entries.measurements
var unit by Entries.unit var unit by Entries.unit
var input by Entries.input
var hardware by Entries.hardware
var project by Project referencedOn Entries.projectId var project by Project referencedOn Entries.projectId
var label by Label referencedOn Entries.labelId var version by Version referencedOn Entries.versionId
} }
@@ -34,17 +34,20 @@ object Projects : UuidTable() {
} }
} }
object Labels : UuidTable() { object Versions : UuidTable() {
val label = varchar("label", 255) val label = varchar("label", 255)
val color = varchar("color", 7) val color = varchar("color", 7)
val meta = text("meta", eagerLoading = true)
val projectId = reference("project_id", Projects, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) val projectId = reference("project_id", Projects, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
} }
object Entries : UuidTable() { object Entries : UuidTable() {
val projectId = reference("project_id", Projects, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) val projectId = reference("project_id", Projects, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val labelId = reference("label", Labels, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) val versionId = reference("version", Versions, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val timestamp = timestamp("timestamp").default(Clock.System.now()) val timestamp = timestamp("timestamp").default(Clock.System.now())
val warmups = list<Float>("warmups") val warmups = list<Float>("warmups")
val measurements = list<Float>("measurements") val measurements = list<Float>("measurements")
val unit = enumeration<TimeUnit>("unit") val unit = enumeration<TimeUnit>("unit")
val input = varchar("input", 255)
val hardware = varchar("hardware", 255)
} }
@@ -6,27 +6,24 @@ import com.jaytux.phoebench.common.EntryRequest
import com.jaytux.phoebench.common.EntryResponse import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.HomeEvent import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.HomeResponse import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.LabelRequest import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.LabelResponse import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.NamedID import com.jaytux.phoebench.common.NamedID
import com.jaytux.phoebench.common.PartialEntryRequest
import com.jaytux.phoebench.common.PartialLabelRequest
import com.jaytux.phoebench.common.PartialProjectRequest import com.jaytux.phoebench.common.PartialProjectRequest
import com.jaytux.phoebench.common.PartialVersionRequest
import com.jaytux.phoebench.common.ProjectEvent import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ProjectRequest import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.ProjectResponse import com.jaytux.phoebench.common.ProjectResponse
import com.jaytux.phoebench.server.Buses import com.jaytux.phoebench.server.Buses
import com.jaytux.phoebench.server.SSEBus
import com.jaytux.phoebench.server.db.Entries import com.jaytux.phoebench.server.db.Entries
import com.jaytux.phoebench.server.db.Entry import com.jaytux.phoebench.server.db.Entry
import com.jaytux.phoebench.server.db.Label import com.jaytux.phoebench.server.db.Version
import com.jaytux.phoebench.server.db.Labels import com.jaytux.phoebench.server.db.Versions
import com.jaytux.phoebench.server.db.Project import com.jaytux.phoebench.server.db.Project
import com.jaytux.phoebench.server.db.Projects import com.jaytux.phoebench.server.db.Projects
import com.jaytux.phoebench.server.db.User import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.RouteError.Companion.success import com.jaytux.phoebench.server.handlers.RouteError.Companion.success
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Transaction import org.jetbrains.exposed.v1.core.Transaction
@@ -55,8 +52,8 @@ object ProjectHandler {
context(trns: Transaction) context(trns: Transaction)
private fun Project.toResponse(user: User) = ProjectResponse( private fun Project.toResponse(user: User) = ProjectResponse(
id.value, name, NamedID(owner.username, owner.id.value), isPublic, isEditableBy(user), id.value, name, NamedID(owner.username, owner.id.value), isPublic, isEditableBy(user),
labels.orderBy(Labels.label to SortOrder.ASC).map { LabelResponse(it.id.value, it.label, it.color) }, labels.orderBy(Versions.label to SortOrder.ASC).map { VersionResponse(it.id.value, it.label, it.color, it.meta) },
entries.orderBy(Entries.timestamp to SortOrder.ASC).map { EntryResponse(it.id.value, it.label.id.value, it.timestamp, it.warmups, it.measurements, it.unit) }) entries.orderBy(Entries.timestamp to SortOrder.ASC).map { EntryResponse(it.id.value, it.version.id.value, it.timestamp, it.warmups, it.measurements, it.unit, it.input, it.hardware) })
fun home(user: User, req: EmptyRequest) = transaction { fun home(user: User, req: EmptyRequest) = transaction {
val own = user.projects.orderBy(Projects.name to SortOrder.ASC).map { val own = user.projects.orderBy(Projects.name to SortOrder.ASC).map {
@@ -127,49 +124,51 @@ object ProjectHandler {
success(EmptyResponse()) success(EmptyResponse())
} }
fun createLabel(user: User, req: LabelRequest) = transaction { fun createVersion(user: User, req: VersionRequest) = transaction {
val proj = accessibleProject(user, req.projectId, true) val proj = accessibleProject(user, req.projectId, true)
if(req.color.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest) if(req.color.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest)
val lbl = Label.new { val ver = Version.new {
label = req.name label = req.name
color = req.color color = req.color
project = proj project = proj
meta = req.meta
} }
val res = LabelResponse(lbl.id.value, lbl.label, lbl.color) val res = VersionResponse(ver.id.value, ver.label, ver.color, ver.meta)
ServerScope.launch { ServerScope.launch {
Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewLabel(res)) Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewVersion(res))
} }
success(res) success(res)
} }
fun updateLabel(user: User, req: Pair<Uuid, PartialLabelRequest>) = transaction { fun updateVersion(user: User, req: Pair<Uuid, PartialVersionRequest>) = transaction {
val lbl = Label.findById(req.first) ?: throw RouteError("Invalid label ID.", HttpStatusCode.NotFound) val ver = Version.findById(req.first) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
lbl.project.isAccessible(user, true) ver.project.isAccessible(user, true)
val changes = req.second val changes = req.second
changes.name?.let { lbl.label = it } changes.name?.let { ver.label = it }
changes.color?.let { changes.color?.let {
if(it.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest) if(it.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest)
lbl.color = it ver.color = it
} }
changes.meta?.let { ver.meta = it }
ServerScope.launch { ServerScope.launch {
Buses.projectBus(lbl.projectId.value).sendAll(ProjectEvent.LabelChanged( Buses.projectBus(ver.projectId.value).sendAll(ProjectEvent.VersionChanged(
LabelResponse(lbl.id.value, lbl.label, lbl.color) VersionResponse(ver.id.value, ver.label, ver.color, ver.meta)
)) ))
} }
success(EmptyResponse()) success(EmptyResponse())
} }
fun deleteLabel(user: User, req: Uuid) = transaction { fun deleteVersion(user: User, req: Uuid) = transaction {
val lbl = Label.findById(req) ?: throw RouteError("Invalid label ID.", HttpStatusCode.NotFound) val ver = Version.findById(req) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
lbl.project.isAccessible(user, true) ver.project.isAccessible(user, true)
lbl.delete() ver.delete()
ServerScope.launch { ServerScope.launch {
Buses.projectBus(lbl.id.value).sendAll(ProjectEvent.LabelDeleted(req)) Buses.projectBus(ver.id.value).sendAll(ProjectEvent.VersionDeleted(req))
} }
success(EmptyResponse()) success(EmptyResponse())
@@ -178,9 +177,9 @@ object ProjectHandler {
fun createEntry(user: User, req: EntryRequest) = transaction { fun createEntry(user: User, req: EntryRequest) = transaction {
val proj = accessibleProject(user, req.projectId, true) val proj = accessibleProject(user, req.projectId, true)
val entry = Entry.new { val entry = Entry.new {
label = when(val l = Label.findById(req.label)) { version = when(val l = Version.findById(req.version)) {
null -> throw RouteError("Invalid label ID.", HttpStatusCode.NotFound) null -> throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
is Label if l.projectId.value != proj.id.value -> throw RouteError("Label is attached to a different project.", HttpStatusCode.Conflict) is Version if l.projectId.value != proj.id.value -> throw RouteError("Version is attached to a different project.", HttpStatusCode.Conflict)
else -> l else -> l
} }
project = proj project = proj
@@ -188,9 +187,11 @@ object ProjectHandler {
timestamp = req.timestamp timestamp = req.timestamp
warmups = req.warmups warmups = req.warmups
unit = req.unit unit = req.unit
input = req.input
hardware = req.hardware
} }
val response = EntryResponse(entry.id.value, entry.label.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit) val response = EntryResponse(entry.id.value, entry.version.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit, entry.input, entry.hardware)
ServerScope.launch { ServerScope.launch {
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response)) Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response))
} }