8 Commits
35 changed files with 970 additions and 357 deletions
+3
View File
@@ -47,3 +47,6 @@ bin/
*.db
.env
.idea/
artifacts/
clients/compose/src/wasmJsMain/resources/config.json
+62
View File
@@ -0,0 +1,62 @@
# PhoeBench
*Introduction to be done*
## Setup
### Server
The server ZIP files in the releases contain all dependencies, so just unzip it where you want to run the server.
First, set up the environment variables (this cannot currently be done using `.env` files, I think):
- `JWT_AUDIENCE`: the audience to use for the JSON Web Tokens authentication, typically set to your URL;
- `JWT_ISSUER`: the issuer for the authentication tokens, typically also set to your URL;
- `JWT_REALM`: the realm for which the authentication tokens are valid, doesn't matter as there is only one "realm" (I use `phoebench`);
- `JWT_SECRET`: a strong secret key to encrypt the authentication tokens (e.g. generated by `gpg --gen-random --armor 1 128`);
- `PORT`: the port to host the server on;
- `PHOEBENCH_SAFE_CLIENT`: (optional) if you plan on hosting a web client, set this to its URL (for CORS requests);
- `PHOEBENCH_DEV_CLIENT_PORT`: (development only) port to enable CORS requests from `localhost` and `127.0.0.1`.
Next, set up the database variables (recommended through a `.env` file):
- `DATABASE_URL`: JDBC URL to connect to the database;
- `DATABASE_DRIVER`: fully-qualified name of the JDBC driver to connect to the database with;
- `DATABASE_USER`: (if needed) username for the database;
- `DATABASE_PASSWORD`: (if needed) password for the database.
The server currently supports SQLite and MariaDB as databases, using the following settings:
```
# SQLite
DATABASE_URL=jdbc:sqlite:/path/to/db/file.db
DATABASE_DRIVER=org.sqlite.JDBC
# SQLite does not need user/password
# MariaDB
DATABASE_URL=jdbc:mariadb://host:port/database-name
DATABASE_DRIVER=org.mariadb.jdbc.Driver
DATABASE_USER=username
DATABASE_PASSWORD=pass
```
For SQLite, don't forget to substitute `/path/to/db/file.db` (with the path to where the database should be stored).
For MariaDB, don't forget to substitute `host`, `port`, `database-name`, `username`, and `pass` (`host:port` will typically be `localhost:3306`).
To use MariaDB, you will need to create the database and user through its administration interface.
### JVM Client
The JVM client does not require any additional setup.
Simply unzip the client ZIP for your platform (currently only built for Linux), and run `bin/com.jaytux.phoebench.clients` from the unzip location.
However, to avoid having to log in again every time you start the app, it is recommended to ensure you have a working keyring set up.
### WASM Client
The WASM/JS client ZIP contains files that can be statically served, including all dependencies.
As the WASM files can take some time to load over the network, it is recommended to modify the `index.html` file to contain some sort of loading screen.
As for setup - you need to create a file called `config.json` with the following content:
```json
{
"server": "server url"
}
```
Replace `server url` with the URL of the server to connect to (don't forget to add it as `PHOEBENCH_SAFE_CLIENT` to the server, see above).
For deployment, this file should be in the same directory where the ZIP was extracted.
For development, it should be under `/clients/compose/src/wasmJsMain/resources/` to ensure correct loading.
### CLI Client
The CLI client does not require any additional setup, simply use Java to run the JAR: `java -jar phoebench-cli.jar`.
To use the CLI client in scripts, it is recommended to suppress certain warnings: `java --enable-native-access=ALL_UNNAMED -jar phoebench-cli.jar`, as it will otherwise output warning messages that might trip up your scripts.
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -e
./gradlew :clients:cli:shadowJar
./gradlew :clients:compose:createDistributable
./gradlew :clients:compose:wasmJsBrowserProductionWebpack
./gradlew :server:installDist
mkdir -p artifacts
cp clients/cli/build/libs/phoebench-cli.jar artifacts/
(cd clients/compose/build/compose/binaries/main/app/com.jaytux.phoebench.clients && zip phoebench-linux.zip -r bin/ lib/)
cp clients/compose/build/compose/binaries/main/app/com.jaytux.phoebench.clients/phoebench-linux.zip artifacts/
mkdir -p artifacts/wasm
cp clients/compose/build/kotlin-webpack/wasmJs/productionExecutable/* clients/compose/src/wasmJsMain/resources/* artifacts/wasm
(cd artifacts/wasm && zip phoebench-wasm.zip -r ./*)
mv artifacts/wasm/phoebench-wasm.zip artifacts/
rm -fr artifacts/wasm
(cd server/build/install/server/ && zip phoebench-server.zip -r bin/ lib/)
cp server/build/install/server/phoebench-server.zip artifacts
+1 -1
View File
@@ -12,4 +12,4 @@ repositories {
mavenCentral()
}
version = "0.1.0-dev"
version = "1.0.0"
+34 -1
View File
@@ -1,3 +1,5 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
alias(libs.plugins.jvm)
application
@@ -6,7 +8,22 @@ plugins {
}
group = "com.jaytux.phoebench"
version = rootProject.version.toString()
version = "1.0.1"
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val generateVersion = tasks.register<Task>("serverVersion") {
doFirst {
val outFile = versionDirectory.get().file("com/jaytux/phoebench/client/cli/Version.kt").asFile
outFile.parentFile.mkdirs()
outFile.writeText("""
package com.jaytux.phoebench.client.cli
object CliVersion {
const val VERSION="$version"
}
""".trimIndent())
}
}
dependencies {
implementation(kotlin("stdlib"))
@@ -40,4 +57,20 @@ kotlin {
freeCompilerArgs.add("-Xcontext-parameters")
optIn.add("kotlin.uuid.ExperimentalUuidApi")
}
sourceSets {
val main by getting {
kotlin {
srcDir(versionDirectory)
}
}
}
tasks.compileKotlin {
dependsOn(generateVersion)
}
}
tasks.withType<ShadowJar> {
archiveFileName = "phoebench-cli.jar"
}
@@ -6,6 +6,7 @@ import com.github.ajalt.clikt.parameters.groups.single
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.float
import com.github.ajalt.clikt.parameters.types.inputStream
import com.jaytux.phoebench.client.cli.CliVersion
import com.jaytux.phoebench.common.TimeUnit
import io.ktor.util.reflect.*
import io.ktor.utils.io.*
@@ -23,6 +24,10 @@ object CLI {
help = "Disable all interactive input (makes arguments marked with (*) mandatory)"
).flag(default = false)
init {
versionOption(CliVersion.VERSION)
}
override fun run() {
val sub = currentContext.invokedSubcommand
if(sub == null) {
@@ -98,7 +103,7 @@ object CLI {
sealed interface IProjectIdentification
sealed interface ILabelIdentification
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
sealed interface IData<T> {
@@ -168,22 +173,23 @@ object CLI {
}
@Suppress("unused")
class AddLabel : CliktCommand(name = "add-label") {
class AddVersion : CliktCommand(name = "add-version") {
val parent by requireObject<Project>()
val name by option("--name", help = "Set the label's name (*)")
val color by option("--color", help = "Set the label's color (*)").check("Color must be specified in RGB-hex-format (#ABCDEF)") {
val name by option("--name", help = "Set the version's name (*)")
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" }
}
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")
class AddData : CliktCommand(name = "add-data") {
val parent by requireObject<Project>()
val label by mutuallyExclusiveOptions<ILabelIdentification>(
option("--label-id", help = "Set the label by UUID.").convert { ID(Uuid.parse(it)) },
option("--label", help = "Set the label by name.").convert { LabelName(it) }
val version by mutuallyExclusiveOptions<ILabelIdentification>(
option("--version-id", help = "Set the version by UUID.").convert { ID(Uuid.parse(it)) },
option("--version", help = "Set the version by name.").convert { VersionName(it) }
).single()
val warmup by mutuallyExclusiveOptions<IData<Float>>(
option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
@@ -201,8 +207,17 @@ object CLI {
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 }
).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)
}
}
@Suppress("unused")
class Version : CliktCommand(name = "version") {
override fun run() {
echo("phoebench-cli version ${CliVersion.VERSION}")
}
}
}
@@ -80,9 +80,10 @@ object Client {
val res = route.call(client, body)
res
} catch (e: CancellationException) {
println("Coroutine calling ${route.verb} ${route.pattern} was cancelled.")
ErrorResponse("Coroutine calling ${route.verb} ${route.pattern} was cancelled.").error()
} catch (e: Exception) {
// println("Call to ${route.pattern} ran into an exception")
println("Call to ${route.pattern} ran into an exception: ${e.message} (${e::class.simpleName})")
ErrorResponse(e.message ?: "Unknown error.").error()
}
}
@@ -1,7 +1,10 @@
package com.jaytux.phoebench.clients.cli
import kotlin.system.exitProcess
fun main(args: Array<String>) {
Client.forceGloballyInitialized()
CLI.main(args)
Client.shutdown()
exitProcess(0) // because D-Bus threads...
}
@@ -1,14 +1,63 @@
package com.jaytux.phoebench.clients.cli
import com.github.javakeyring.BackendNotSupportedException
import com.github.javakeyring.Keyring
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import java.util.prefs.Preferences
import kotlin.uuid.Uuid
object PersistentStorage {
private val json = Json
const val SERVICE = "com.jaytux.phoebench.cli"
private var _backend: IBackend = KeyringBackend
interface IBackend {
fun read(key: String): String?
fun write(key: String, value: String)
fun erase(key: String)
}
private object KeyringBackend : IBackend {
override fun read(key: String): String? = try {
Keyring.create().use { it.getPassword(SERVICE, key) }
} catch(e: Exception) {
_backend = PreferencesBackend
_backend.read(key)
}
override fun write(key: String, value: String) {
try {
Keyring.create().use { it.setPassword(SERVICE, key, value) }
} catch(e: Exception) {
_backend = PreferencesBackend
_backend.write(key, value)
}
}
override fun erase(key: String) {
try {
Keyring.create().use { it.deletePassword(SERVICE, key) }
} catch (e: Exception) {
_backend = PreferencesBackend
_backend.erase(key)
}
}
}
private object PreferencesBackend : IBackend {
private val prefs = Preferences.userNodeForPackage(this::class.java)
override fun read(key: String): String? = prefs.get(key, null)
override fun write(key: String, value: String) {
prefs.put(key, value)
}
override fun erase(key: String) {
prefs.remove(key)
}
}
class StoredProperty<T>(
private val _key: String,
@@ -20,21 +69,15 @@ object PersistentStorage {
)
fun load(): T? = runCatching {
Keyring.create().use {
it.getPassword(SERVICE, _key)
}
_backend.read(_key)
}.getOrNull()?.let { _fromString(it) }
fun save(value: T) = runCatching {
val keyring = Keyring.create().use {
it.setPassword(SERVICE, _key, _toString(value))
}
_backend.write(_key, _toString(value))
}.onFailure { println("Failed to write to OS keyring: ${it.message}") }.ignore()
fun erase() = runCatching {
val keyring = Keyring.create().use {
it.deletePassword(SERVICE, _key)
}
_backend.erase(_key)
}.ignore()
}
@@ -1,16 +1,11 @@
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.common.ApiRoute
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.EntryRequest
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.LabelRequest
import com.jaytux.phoebench.common.LabelResponse
import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.Routes
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.map
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.exists
import kotlin.io.path.inputStream
@@ -75,15 +67,15 @@ object ProjectHandlers {
System.err.println("Failed to get project details: ${it.msg}")
}) {
println("Project ${it.owner.name}/${it.name} [${it.id}]:")
println("${it.usedLabels.size} labels:")
it.usedLabels.forEach { l ->
println(" - [${l.id}] ${l.name} (with color ${l.color})")
println("${it.usedLabels.size} versions:")
it.usedLabels.forEach { v ->
println(" - [${v.id}] VERSION ${v.name} (with color ${v.color}); metadata: ${v.meta}")
}
val map = it.usedLabels.associateBy { l -> l.id }
println("\n${it.entries.size} entries:")
it.entries.forEach { e ->
println(" - [${e.id}] Entry labeled ${map[e.labelId]?.name ?: "<invalid label>"} at ${e.timestamp.fmt()} " +
println(" - [${e.id}] ENTRY versioned ${map[e.labelId]?.name ?: "<invalid version>"} at ${e.timestamp.fmt()} " +
"(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})")
}
}
@@ -100,9 +92,9 @@ 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 useName = name.maybePrompt("label name") { it }
val useName = name.maybePrompt("version name") { it }
val useColor = color.maybePrompt("color") {
val check = it.length == 7 && it[0] == '#' && it.substring(1, it.length).all { c -> c.isDigit() || c in "ABCDEF" }
if(check) it
@@ -111,13 +103,14 @@ object ProjectHandlers {
exitProcess(-1)
}
}
val useVersion = version.maybePrompt("version") { it }
tryAuthenticated {
id.toId().bind { projectId ->
Client.callRoute(Routes.Label.new, LabelRequest(useName, useColor, projectId))
Client.callRoute(Routes.Version.new, VersionRequest(useName, useColor, useVersion, projectId))
}
}.fold({
System.err.println("Could not create label: ${it.msg}")
System.err.println("Could not create version: ${it.msg}")
}) {
println("Label ${it.name} (${it.color}) [${it.id}] created.")
}
@@ -139,11 +132,11 @@ object ProjectHandlers {
label: CLI.Commands.Project.ILabelIdentification?,
warmup: CLI.Commands.Project.IData<Float>?,
measurement: CLI.Commands.Project.IData<Float>?,
unit: TimeUnit?
unit: TimeUnit?, input: String?, hardware: String?
) {
val projectId = ensureProjectIdentification(project)
val labelId = label.maybePrompt("label name") {
CLI.Commands.Project.LabelName(it)
val versionId = label.maybePrompt("version name") {
CLI.Commands.Project.VersionName(it)
}
val warmupData = warmup.ensure("warmup").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 {
projectId.toId().bind { pId ->
when(labelId) {
is CLI.Commands.Project.ID -> labelId.id.value()
is CLI.Commands.Project.LabelName -> Client.callRoute(Routes.Project.get, pId).bind {
it.usedLabels.firstOrNull { l -> l.name == labelId.name }?.id?.value()
?: ErrorResponse("Label ${labelId.name} does not exist in this project.").error()
when(versionId) {
is CLI.Commands.Project.ID -> versionId.id.value()
is CLI.Commands.Project.VersionName -> Client.callRoute(Routes.Project.get, pId).bind {
it.usedLabels.firstOrNull { l -> l.name == versionId.name }?.id?.value()
?: ErrorResponse("Version ${versionId.name} does not exist in this project.").error()
}
}.map { pId to it }
}.bind { (pId, lId) ->
}.bind { (pId, vId) ->
Client.callRoute(Routes.Entry.new, EntryRequest(
lId, Clock.System.now(), pId,
warmupData, measureData, timeUnit
vId, Clock.System.now(), pId,
warmupData, measureData, timeUnit, useInput, useHardware
))
}
}
+56 -1
View File
@@ -10,6 +10,24 @@ plugins {
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.buildconfig)
}
version = "1.0.1"
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val generateVersion = tasks.register<Task>("clientVersion") {
doFirst {
val outFile = versionDirectory.get().file("com/jaytux/phoebench/client/Version.kt").asFile
outFile.parentFile.mkdirs()
outFile.writeText("""
package com.jaytux.phoebench.client
object ClientVersion {
const val VERSION="$version"
}
""".trimIndent())
}
}
kotlin {
@@ -86,6 +104,20 @@ kotlin {
wasmJsMain.dependencies {
implementation(libs.ktor.client.js)
}
commonMain {
kotlin {
srcDir(versionDirectory)
}
}
}
targets.all {
compilations.all {
compileTaskProvider.configure {
dependsOn(generateVersion)
}
}
}
}
@@ -96,7 +128,30 @@ compose.desktop {
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.jaytux.phoebench.clients"
packageVersion = rootProject.version.toString().split('-')[0]
packageVersion = version.toString()
jvmArgs += listOf("--enable-native-access=ALL-UNNAMED")
includeAllModules = true
buildTypes.release.proguard {
configurationFiles.from(file("proguard-rules.pro"))
}
}
}
}
fun envValue(key: String): String? {
val fromEnv = providers.environmentVariable(key).orNull
if(fromEnv != null) return fromEnv
val envFile = rootProject.file(".env")
if(!envFile.exists()) return null
return envFile.useLines { lines ->
lines.map { it.trim() }.filter { it.isNotBlank() && !it.startsWith('#') && '=' in it }
.map { line ->
val (k, v) = line.split('=', limit = 2)
k.trim() to v.trim().removeSurrounding("\"").removeSurrounding("'")
}
.firstOrNull { it.first == key }?.second
}
}
+11
View File
@@ -0,0 +1,11 @@
-keep class com.github.javakeyring.** { *; }
-dontwarn com.github.javakeyring.**
-keep class com.sun.jna.** { *; }
-dontwarn com.sun.jna.**
-keepclassmembers class * extends com.sun.jna.Structure {
*** *;
}
-keepclassmembers class * extends com.sun.jna.Callback {
*** *;
}
@@ -76,7 +76,7 @@ class Client private constructor(private val _auth: AuthProvider) {
println("Call to ${route.pattern} [$ctr] was cancelled")
ErrorResponse(COROUTINE_CANCELLED).error()
} 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()
}
}
@@ -1,5 +1,6 @@
package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ClipEntry
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
@@ -21,3 +22,8 @@ expect fun persistentStore(): IStore
expect fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient
expect suspend fun String.toClipEntry(): ClipEntry
expect fun format(f: Float): String
@Composable
expect fun ServerSelect(server: String, onUpdate: (String) -> Unit)
@@ -77,13 +77,6 @@ fun Pair<Instant, Instant>.fmtRange() = "Between ${first.fmt()} and ${second.fmt
infix fun Instant.inRange(range: Pair<Instant, Instant>) = range.first <= this && this <= range.second
fun Float.fmt(): String {
val integer = toInt()
val decimals = this - integer
val decInt = (decimals * 1000).roundToInt().toFloat() / 1000f
return (integer + decInt).toString()
}
inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T) -> X): List<T> {
val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy)
if(insertionPoint >= 0) return this
@@ -91,3 +84,42 @@ inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T
return toMutableList().apply { add(index, elem) }
}
fun List<Float>.geomean(): Float {
return (size.toFloat() / sumOf { 1.0 / it.toDouble() }).toFloat()
}
infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
fun <T1, T2, T3> List<Triple<T1, T2, T3>>.unzip(): Triple<List<T1>, List<T2>, List<T3>> {
val l1 = ArrayList<T1>(size)
val l2 = ArrayList<T2>(size)
val l3 = ArrayList<T3>(size)
for((t1, t2, t3) in this) {
l1.add(t1)
l2.add(t2)
l3.add(t3)
}
return l1 to l2 app l3
}
data class Tuple4<T1, T2, T3, T4>(val t1: T1, val t2: T2, val t3: T3, val t4: T4)
infix fun <T1, T2, T3, T4> Triple<T1, T2, T3>.app(t4: T4) = Tuple4(first, second, third, t4)
fun <T1, T2, T3, T4> List<Tuple4<T1, T2, T3, T4>>.unzip(): Tuple4<List<T1>, List<T2>, List<T3>, List<T4>> {
val l1 = ArrayList<T1>(size)
val l2 = ArrayList<T2>(size)
val l3 = ArrayList<T3>(size)
val l4 = ArrayList<T4>(size)
for((t1, t2, t3, t4) in this) {
l1.add(t1)
l2.add(t2)
l3.add(t3)
l4.add(t4)
}
return l1 to l2 app l3 app l4
}
@@ -8,15 +8,13 @@ import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.EntryRequest
import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.LabelRequest
import com.jaytux.phoebench.common.LabelResponse
import com.jaytux.phoebench.common.PartialEntryRequest
import com.jaytux.phoebench.common.PartialLabelRequest
import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.PartialProjectRequest
import com.jaytux.phoebench.common.PartialVersionRequest
import com.jaytux.phoebench.common.ProjectResponse
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.TimeUnit
import kotlinx.datetime.TimeZone
import kotlin.time.Instant
import kotlin.uuid.Uuid
@@ -25,12 +23,12 @@ interface IProjectRepo {
suspend fun update(name: String? = null, isPublic: Boolean? = null): Either<ErrorResponse, Unit>
suspend fun delete(): Either<ErrorResponse, Unit>
suspend fun newLabel(name: String, color: Color): Either<ErrorResponse, LabelResponse>
suspend fun updateLabel(id: Uuid, name: String? = null, color: Color? = null): Either<ErrorResponse, Unit>
suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit>
suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse>
suspend fun updateVersion(id: Uuid, name: String? = null, color: Color? = null, meta: String? = null): Either<ErrorResponse, Unit>
suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit>
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>
companion object {
@@ -44,19 +42,19 @@ interface IProjectRepo {
override suspend fun delete(): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.delete, _projectId).ignoreValue()
override suspend fun newLabel(name: String, color: Color): Either<ErrorResponse, LabelResponse> =
_client.callRoute(Routes.Label.new, LabelRequest(name, color.hexString(), _projectId))
override suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse> =
_client.callRoute(Routes.Version.new, VersionRequest(name, color.hexString(), meta, _projectId))
override suspend fun updateLabel(id: Uuid, name: String?, color: Color?): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Label.update, id to PartialLabelRequest(name, color?.hexString())).ignoreValue()
override suspend fun updateVersion(id: Uuid, name: String?, color: Color?, meta: String?): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Version.update, id to PartialVersionRequest(name, color?.hexString(), meta)).ignoreValue()
override suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Label.delete, id).ignoreValue()
override suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Version.delete, id).ignoreValue()
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> =
_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> =
_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.immutable
import com.jaytux.phoebench.clients.insort
import com.jaytux.phoebench.clients.systemTz
import com.jaytux.phoebench.clients.withScope
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.TimeUnit
import com.jaytux.phoebench.common.foldSuspend
import kotlinx.coroutines.Job
import kotlin.time.Clock
import kotlin.time.Instant
@@ -32,7 +30,7 @@ class ProjectVM(
private val _sseRepo: ISSERepo = ISSERepo.default(_client),
private val _forceBack: () -> Unit
) : 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 {
private val _snack by lazy { SnackProvider.get() }
private val _errColor = Color(red = 252, green = 20, blue = 182)
@@ -50,18 +48,18 @@ class ProjectVM(
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>,
val measurements: List<Float>, val nativeUnit: TimeUnit) {
data class Entry(val id: Uuid, val label: Version, val timeStamp: Instant, val warmups: List<Float>,
val measurements: List<Float>, val nativeUnit: TimeUnit, val input: String, val hardware: String) {
companion object {
fun fromResponse(it: EntryResponse, map: Map<Uuid, Label>) = Entry(
it.id, map[it.labelId] ?: Label.invalid, it.timestamp,
it.warmups, it.measurements, it.unit
fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>) = Entry(
it.id, map[it.labelId] ?: Version.invalid, it.timestamp,
it.warmups, it.measurements, it.unit, it.input, it.hardware
)
}
}
@@ -70,15 +68,19 @@ class ProjectVM(
private val _owner = mutableStateOf<String?>(null)
private val _public = 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 _inputs = mutableStateOf(setOf<String>())
private val _hardware = mutableStateOf(setOf<String>())
val name = _name.immutable()
val owner = _owner.immutable()
val public = _public.immutable()
val editable = _editable.immutable()
val labels = _labels.immutable()
val versions = _labels.immutable()
val entries = _entries.immutable()
val inputs = _inputs.immutable()
val hardware = _hardware.immutable()
private var _job: Job? = null
@@ -100,8 +102,16 @@ class ProjectVM(
_owner.value = it.owner.name
_public.value = it.isPublic
_editable.value = it.isEditable
_labels.value = it.usedLabels.associate { l -> l.id to Label.fromResponse(l) }
_entries.value = it.entries.map { e -> Entry.fromResponse(e, _labels.value) }
_labels.value = it.usedLabels.associate { l -> l.id to Version.fromResponse(l) }
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) {
ProjectEvent.Deleted -> back()
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.LabelDeleted -> _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.NewLabel -> _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.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)
_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 -> {
_name.value = event.changes.name
_owner.value = event.changes.owner.name
@@ -124,7 +138,9 @@ class ProjectVM(
fun update(name: String?, isPublic: Boolean?) {
withScope {
_repo.update(name, isPublic).snackOr { refresh() }
_repo.update(name, isPublic).snackOr {
if(_job == null) refresh()
}
}
}
@@ -139,35 +155,42 @@ class ProjectVM(
_forceBack()
}
fun mkLabel(name: String, color: Color) {
fun mkLabel(name: String, color: Color, meta: String) {
withScope {
_repo.newLabel(name, color).snackOr {
_labels.value += it.id to Label(it.id, it.name, it.color)
_repo.newVersion(name, color, meta).snackOr {
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 {
val old = _labels.value[id] ?: return@withScope
_repo.updateLabel(id, name, color).snackOr {
_labels.value += id to Label(id, name ?: old.name, color?.hexString() ?: old.colorStr, color ?: old.uiColor)
_repo.updateVersion(id, name, color).snackOr {
if(_job == null)
_labels.value += id to Version(id, name ?: old.name, color?.hexString() ?: old.colorStr, meta ?: old.meta, color ?: old.uiColor)
}
}
}
fun deleteLabel(id: Uuid) {
fun deleteVersion(id: Uuid) {
withScope {
_repo.deleteLabel(id).snackOr {
_repo.deleteVersion(id).snackOr {
if(_job == null)
_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 {
_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)
_inputs.value += input
_hardware.value += hardware
}
}
}
}
@@ -175,6 +198,7 @@ class ProjectVM(
fun deleteEntry(id: Uuid) {
withScope {
_repo.deleteEntry(id).snackOr {
if(_job == null)
_entries.value = _entries.value.filter { it.id != id }
}
}
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
@@ -28,13 +27,10 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -49,7 +45,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.backhandler.BackHandler
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalDensity
@@ -60,7 +55,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel
import com.composables.icons.lucide.ChevronLeft
import com.composables.icons.lucide.Delete
import com.composables.icons.lucide.LogOut
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Pencil
@@ -68,8 +62,10 @@ import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.ShieldPlus
import com.composables.icons.lucide.Trash
import com.composables.icons.lucide.X
import com.jaytux.phoebench.client.ClientVersion
import com.jaytux.phoebench.clients.AuthProvider
import com.jaytux.phoebench.clients.Client
import com.jaytux.phoebench.clients.ServerSelect
import com.jaytux.phoebench.clients.SnackProvider
import com.jaytux.phoebench.clients.data.HomeVM
import com.jaytux.phoebench.clients.data.mutableStateSetOf
@@ -82,13 +78,11 @@ import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.LoginRequest
import com.jaytux.phoebench.common.NamedID
import com.jaytux.phoebench.common.ProtocolVersion
import com.jaytux.phoebench.common.RefreshRequest
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.SignupRequest
import com.jaytux.phoebench.common.UserListResponse
import com.jaytux.phoebench.common.fold
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlin.uuid.Uuid
@Composable
@@ -165,6 +159,16 @@ fun RootView(auth: AuthProvider, client: Client) = Surface(tonalElevation = 1.dp
}
}
@Composable
fun DefaultServerSelect(server: String, onUpdate: (String) -> Unit) {
OutlinedTextField(server, onUpdate, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true)
}
fun constantServerSelect(server: String): (@Composable (server: String, onUpdate: (String) -> Unit) -> Unit) = { _, onUpdate ->
onUpdate(server)
OutlinedTextField(server, {}, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true, enabled = false)
}
@Composable
fun LoginView(server: String?, error: String?, onSwitchSignup: () -> Unit, onLogin: (server: String, user: String, pass: String) -> Unit) {
var serverUrl by remember { mutableStateOf(server ?: "https://") }
@@ -176,7 +180,7 @@ fun LoginView(server: String?, error: String?, onSwitchSignup: () -> Unit, onLog
Column(Modifier.padding(5.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Log in", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(serverUrl, { serverUrl = it }, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true)
ServerSelect(serverUrl) { serverUrl = it }
OutlinedTextField(username, { username = it }, Modifier.fillMaxWidth(), label = { Text("Username") }, singleLine = true)
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") }, visualTransformation = PasswordVisualTransformation(), singleLine = true)
@@ -208,7 +212,7 @@ fun SignupView(server: String?, error: String?, onSwitchLogin: () -> Unit, onSig
Column(Modifier.padding(5.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Sign up", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(serverUrl, { serverUrl = it }, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true)
ServerSelect(serverUrl) { serverUrl = it }
OutlinedTextField(inviteCode, { inviteCode = it }, Modifier.fillMaxWidth(), label = { Text("Invite code") }, singleLine = true)
OutlinedTextField(username, { username = it }, Modifier.fillMaxWidth(), label = { Text("Username") }, singleLine = true)
@@ -250,7 +254,7 @@ fun AuthenticatedRoot() {
Scaffold(
topBar = {
TopAppBar(
title = { Text("PhoeBench", style = MaterialTheme.typography.headlineLarge) },
title = { Text("PhoeBench v${ClientVersion.VERSION}", style = MaterialTheme.typography.headlineLarge) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
@@ -13,6 +13,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.backhandler.BackHandler
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.onGloballyPositioned
@@ -24,35 +25,18 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel
import com.composables.icons.lucide.*
import com.jaytux.phoebench.clients.darken
import com.jaytux.phoebench.clients.*
import com.jaytux.phoebench.clients.data.ProjectVM
import com.jaytux.phoebench.clients.data.mutableStateSetFrom
import com.jaytux.phoebench.clients.dualLerp
import com.jaytux.phoebench.clients.fmt
import com.jaytux.phoebench.clients.fmtRange
import com.jaytux.phoebench.clients.inRange
import com.jaytux.phoebench.clients.nonEq
import com.jaytux.phoebench.clients.randomColor
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.TimeUnit
import com.jaytux.phoebench.common.asError
import com.jaytux.phoebench.common.asValue
import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.isValue
import com.jaytux.phoebench.common.value
import com.jaytux.phoebench.clients.data.mutableStateSetOf
import com.jaytux.phoebench.common.*
import com.kborowy.colorpicker.KolorPicker
import io.github.koalaplot.core.bar.DefaultBar
import io.github.koalaplot.core.bar.VerticalBarPlot
import io.github.koalaplot.core.line.LinePlot
import io.github.koalaplot.core.style.KoalaPlotTheme
import io.github.koalaplot.core.style.LineStyle
import io.github.koalaplot.core.xygraph.AxisContent
import io.github.koalaplot.core.xygraph.DefaultPoint
import io.github.koalaplot.core.xygraph.XYGraph
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.rememberFloatLinearAxisModel
import io.github.koalaplot.core.xygraph.rememberGridStyle
import io.github.koalaplot.core.xygraph.*
import kotlin.time.Instant
import kotlin.uuid.Uuid
@@ -66,7 +50,7 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
val owner by vm.owner
val public by vm.public
val editable by vm.editable
val labels by vm.labels
val labels by vm.versions
var editing by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(false) }
@@ -93,6 +77,7 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
Box(Modifier.fillMaxSize().padding(15.dp)) {
ProjectPlotArea(vm)
if(editable) {
if(addOpen) {
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
Box(Modifier.clickable { addOpen = false }.fillMaxSize())
@@ -139,6 +124,7 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
}
}
}
}
if(editing) ProjectEditDialog(name ?: "Unnamed Project", public, { editing = false }) { name, public ->
vm.update(name, public)
@@ -148,16 +134,19 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
vm.delete()
}
if(addingLabel) AddLabelDialog({ addingLabel = false; addOpen = false }) { name, color ->
vm.mkLabel(name, color)
if(addingLabel) AddVersionDialog({ addingLabel = false; addOpen = false }) { name, color, meta ->
vm.mkLabel(name, color, meta)
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 },
onAddLbl = { name, lbl -> vm.mkLabel(name, lbl) }
) { label, warmups, measurements, unit ->
vm.mkEntry(label, warmups, measurements, unit)
onAddLbl = { name, lbl, meta -> vm.mkLabel(name, lbl, meta) }
) { label, warmups, measurements, unit, input, hardware ->
vm.mkEntry(label, warmups, measurements, unit, input, hardware)
addOpen = false
}
}
@@ -205,48 +194,70 @@ fun ConfirmDeleteProjectDialog(name: String, onCancel: () -> Unit, onDelete: ()
}
@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) {
var name by remember { mutableStateOf("") }
var color by remember { mutableStateOf(randomColor()) }
var meta by remember { mutableStateOf("") }
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
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") })
Spacer(Modifier.height(10.dp))
KolorPicker(color, { color = it }, alphaTrackVisible = false, modifier = Modifier.aspectRatio(1f))
Spacer(Modifier.height(10.dp))
OutlinedTextField(meta, { meta = it }, Modifier.fillMaxWidth(), label = { Text("Additional information") })
Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, {
onAdd(name.trim(), color)
}, confirmText = "Create", canConfirm = name.trim().isNotBlank())
onAdd(name.trim(), color, meta.trim())
}, confirmText = "Create", canConfirm = name.trim().isNotBlank() && meta.trim().isNotBlank())
}
}
}
}
@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) {
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))
Column {
Row {
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),
)
}
if(lbl.meta.isNotBlank())
Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(0.75f))
else
Text("(no meta-data for version)", style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(0.5f))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddEntryDialog(
labels: Map<Uuid, ProjectVM.Label>,
onCancel: () -> Unit, onAddLbl: (name: String, color: Color) -> Unit,
onAdd: (label: ProjectVM.Label, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit) -> Unit
labels: Map<Uuid, ProjectVM.Version>, usedInputs: Set<String>, usedHardware: Set<String>,
onCancel: () -> Unit, onAddLbl: (name: String, color: Color, meta: String) -> Unit,
onAdd: (label: ProjectVM.Version, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit, input: String, hardware: String) -> Unit
) {
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 measurements by remember { mutableStateOf("") }
var unit by remember { mutableStateOf(TimeUnit.SECONDS) }
var input by remember { mutableStateOf("") }
var hardware by remember { mutableStateOf("") }
var addingLabel by remember { mutableStateOf(false) }
var warmupParsed by remember { mutableStateOf<Either<String, List<Float>>>(listOf<Float>().value()) }
@@ -281,12 +292,12 @@ fun AddEntryDialog(
tonalElevation = 2.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 }) {
labels.values.forEach { lbl ->
DropdownMenuItem({ QuickLabel(lbl) }, { label = lbl; dropDownOpen = false })
DropdownMenuItem({ QuickVersion(lbl) }, { label = lbl; dropDownOpen = false })
}
HorizontalDivider(Modifier.height(1.dp))
@@ -308,6 +319,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))
Text("Data (separate data points by commas)")
Spacer(Modifier.height(5.dp))
@@ -326,20 +370,51 @@ fun AddEntryDialog(
Spacer(Modifier.height(5.dp))
CancelConfirmXRow(onCancel, {
onAdd(label!!, warmupParsed.asValue()!!, measureParsed.asValue()!!, unit)
}, confirmText = "Create", canConfirm = label != null && warmupParsed.isValue() && measureParsed.isValue())
onAdd(label!!, warmupParsed.asValue()!!, measureParsed.asValue()!!, unit, input, hardware)
}, 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
fun ProjectPlotArea(vm: ProjectVM){
val entries by vm.entries
val labels by vm.labels
val versions by vm.versions
val editable by vm.editable
val allInputs by vm.inputs
val allHardware by vm.hardware
var filteredEntries by remember { mutableStateOf(entries) }
var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) }
var timeMax by remember { mutableStateOf(Instant.DISTANT_FUTURE) }
var timeFilter by remember { mutableStateOf(0f..1f) }
var timeFilterString by remember { mutableStateOf("") }
val versionFilter = remember(versions) { mutableStateSetFrom(versions.values) }
val labelVersionFilter by versionFilter.revision
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, allInputs, allHardware, timeFilter, timeMin, timeMax, labelVersionFilter, filterInput, filterHardware) {
val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
enabledInputs.clear()
enabledHardware.clear()
filteredEntries = entries.filter { entry ->
if(entry.label !in versionFilter) return@filter false
if(!(entry.timeStamp inRange timeRange)) return@filter false
if(filterInput != null && entry.input != filterInput) return@filter false
if(filterHardware != null && entry.hardware != filterHardware) return@filter false
enabledInputs.add(entry.input)
enabledHardware.add(entry.hardware)
true
}
}
var displayWarmups by remember { mutableStateOf(false) }
var currentUnit by remember { mutableStateOf(TimeUnit.SECONDS) }
@@ -348,26 +423,14 @@ fun ProjectPlotArea(vm: ProjectVM){
var yMin by remember { mutableStateOf(0f) }
var yMax by remember { mutableStateOf(1f) }
var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) }
var timeMax by remember { mutableStateOf(Instant.DISTANT_FUTURE) }
var timeFilter by remember { mutableStateOf(0f..1f) }
var timeFilterString by remember { mutableStateOf("") }
val labelFilter = remember(labels) { mutableStateSetFrom(labels.values) }
val labelFilterKey by labelFilter.revision
LaunchedEffect(entries, displayWarmups, currentUnit, timeFilter, timeMin, timeMax, labelFilterKey) {
LaunchedEffect(filteredEntries, displayWarmups, currentUnit) {
var maxX = 0f
var minY = 0f
var maxY = 0f
val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
renderableEntries = entries.mapNotNull { entry ->
renderableEntries = filteredEntries.map { entry ->
val use = if(displayWarmups) entry.warmups else entry.measurements
maxX = maxOf(maxX, use.size.toFloat())
if(entry.label !in labelFilter) return@mapNotNull null
if(!(entry.timeStamp inRange timeRange)) return@mapNotNull null
use.mapIndexed { idx, it ->
val converted = entry.nativeUnit.convertTo(currentUnit, it)
minY = minOf(minY, converted)
@@ -381,6 +444,12 @@ fun ProjectPlotArea(vm: ProjectVM){
yMax = maxY
}
var deletingVersion by remember { mutableStateOf<ProjectVM.Version?>(null) }
var baseline by remember { mutableStateOf<ProjectVM.Version?>(null) }
var baselineComparison by remember { mutableStateOf<List<Tuple4<String, Float, Color, Float>>?>(null) }
var baselineMax by remember { mutableStateOf(2.0f) }
LaunchedEffect(entries) {
timeMin = entries.minOfOrNull { it.timeStamp } ?: Instant.DISTANT_PAST
timeMax = entries.maxOfOrNull { it.timeStamp } ?: Instant.DISTANT_FUTURE
@@ -390,8 +459,39 @@ fun ProjectPlotArea(vm: ProjectVM){
timeFilterString = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive).fmtRange()
}
LaunchedEffect(filteredEntries, baseline, currentUnit) {
var max = 0.0f
baselineComparison = baseline?.let { bl ->
val perVersion = mutableMapOf<ProjectVM.Version, Pair<Float, Float>>()
filteredEntries.forEach { entry ->
val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum()
perVersion[entry.label] = perVersion[entry.label]?.let {
(previous, prCount) -> (previous + corrected) to (prCount + entry.measurements.size)
} ?: (corrected to entry.measurements.size.toFloat())
}
val means = perVersion.mapValues { (_, v) -> v.first / v.second }
val baselineData = means[bl] ?: return@let null
means.mapNotNull { (ver, data) ->
val speedup = baselineData / data
max = maxOf(max, speedup)
ver.name to speedup app ver.uiColor app data
}
}
baselineMax = max
}
val style = rememberAxisStyle()
val lineColor = SolidColor(Color.LightGray.copy(alpha = 0.25f))
val gridStyle = rememberGridStyle(
horizontalMajorStyle = KoalaPlotTheme.axis.majorGridlineStyle?.copy(brush = lineColor),
horizontalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor),
verticalMajorStyle = KoalaPlotTheme.axis.majorGridlineStyle?.copy(brush = lineColor),
verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor)
)
Row(Modifier.fillMaxSize().padding(20.dp)) {
Box(Modifier.weight(0.66f).fillMaxHeight()) {
Row(Modifier.weight(0.66f).fillMaxHeight()) {
if(entries.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
@@ -448,24 +548,16 @@ fun ProjectPlotArea(vm: ProjectVM){
}
Spacer(Modifier.height(15.dp))
val style = rememberAxisStyle()
val lineColor = SolidColor(Color.LightGray.copy(alpha = 0.25f))
val gridStyle = rememberGridStyle(
horizontalMajorStyle = KoalaPlotTheme.axis.majorGridlineStyle?.copy(brush = lineColor),
horizontalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor),
verticalMajorStyle = KoalaPlotTheme.axis.majorGridlineStyle?.copy(brush = lineColor),
verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor)
)
XYGraph(
rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()),
rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()),
xAxisContent = AxisContent(
labels = { AxisLabel(it.fmt()) },
labels = { AxisLabel(format(it)) },
title = {},
style = style
),
yAxisContent = AxisContent(
labels = { AxisLabel(it.fmt()) },
labels = { AxisLabel(format(it)) },
title = {},
style = style
),
@@ -482,23 +574,37 @@ fun ProjectPlotArea(vm: ProjectVM){
Spacer(Modifier.width(20.dp))
Box(Modifier.weight(0.33f).fillMaxHeight()) {
Column {
Text("Labels", style = MaterialTheme.typography.headlineSmall)
Column(Modifier.weight(0.33f).fillMaxHeight()) {
Row {
Column(Modifier.weight(0.5f)) {
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))
if(labels.isEmpty()) {
if(versions.isEmpty()) {
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 {
LazyColumn(Modifier.padding(start = 5.dp)) {
items(labels.toList().sortedBy { it.second.name }) { (_, lbl) ->
Box(Modifier.fillMaxWidth().clickable { labelFilter.toggle(lbl) }) {
Box {
QuickLabel(lbl)
items(versions.toList().sortedBy { it.second.name }) { (_, lbl) ->
Box(Modifier.fillMaxWidth().clickable {
baseline = null
if(versionFilter.size == versions.size) {
versionFilter.clear()
versionFilter.add(lbl)
}
else {
versionFilter.toggle(lbl)
if(versionFilter.isEmpty()) versionFilter.addAll(versions.values)
}
}) {
Row {
Box(Modifier.weight(1f)) {
QuickVersion(lbl, lbl !in versionFilter)
if(lbl !in labelFilter) {
if (lbl !in versionFilter) {
Box(Modifier.matchParentSize()) {
HorizontalDivider(
Modifier.fillMaxWidth().align(Alignment.Center),
@@ -508,6 +614,18 @@ fun ProjectPlotArea(vm: ProjectVM){
}
}
}
IconButton({ baseline = if(baseline == lbl) null else lbl },
Modifier.align(Alignment.CenterVertically), enabled = lbl in versionFilter) {
if(baseline == lbl) {
Surface(Modifier.padding(5.dp), color = MaterialTheme.colorScheme.inverseSurface, shape = MaterialTheme.shapes.large) {
Icon(Lucide.RulerDimensionLine, "Use as baseline", Modifier.padding(5.dp))
}
}
else Icon(Lucide.RulerDimensionLine, "Use as baseline")
}
if(editable) {
IconButton({ deletingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) {
Icon(Lucide.Trash, "Delete version")
}
}
}
@@ -516,6 +634,115 @@ 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))
}
}
}
Column(Modifier.weight(1f)) {
baselineComparison?.let { blc ->
Spacer(Modifier.height(10.dp))
HorizontalDivider(Modifier.height(1.dp))
Spacer(Modifier.height(10.dp))
Text("Speedup compared to ${baseline?.name ?: "<unknown version>"}", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(10.dp))
val (xs, ys, colors) = blc.unzip()
XYGraph(
xAxisModel = remember(blc) { CategoryAxisModel(xs) },
yAxisModel = rememberFloatLinearAxisModel(0f..baselineMax),
xAxisContent = AxisContent(
labels = { AxisLabel(it) },
title = {},
style = style
),
yAxisContent = AxisContent(
labels = { AxisLabel(format(it)) },
title = {},
style = style
),
modifier = Modifier.weight(1f),
gridStyle = gridStyle
) {
VerticalBarPlot(
xData = xs,
yData = ys,
bar = { x, _, _ -> DefaultBar(brush = SolidColor(colors[x]), modifier = Modifier.fillMaxWidth()) }
)
blc.forEach { (x, y, _, time) ->
XYAnnotation(Point(x, y), AnchorPoint.TopCenter) {
Text("${format(y)}x\n${format(time)} ${currentUnit.disp}", Modifier.padding(top = 5.dp))
}
}
}
}
}
}
}
deletingVersion?.let { rmV ->
ConfirmDeleteVersionDialog(rmV, { deletingVersion = null }) {
vm.deleteVersion(rmV.id)
}
}
}
@Composable
fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit, onDelete: () -> Unit) {
Dialog(onDismissRequest = onCancel) {
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) {
Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(10.dp))
Text("Are you sure you want to delete $version?")
Text("This will also remove all data associated with this version.")
Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
}
}
}
}
@Composable
fun AxisLabel(
@@ -1,8 +1,10 @@
package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.platform.ClipEntry
import com.github.javakeyring.Keyring
import com.jaytux.phoebench.clients.ui.DefaultServerSelect
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import kotlinx.serialization.KSerializer
@@ -56,3 +58,8 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
@OptIn(ExperimentalComposeUiApi::class)
actual suspend fun String.toClipEntry(): ClipEntry =
ClipEntry(StringSelection(this))
actual fun format(f: Float): String = String.format("%.03f", f)
@Composable
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate)
@@ -1,11 +1,13 @@
package com.jaytux.phoebench.clients
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
suspend fun main() {
config = loadConfig()
ComposeViewport(document.body!!) {
App()
}
@@ -1,10 +1,15 @@
package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ClipEntry
import com.jaytux.phoebench.clients.ui.constantServerSelect
import io.ktor.client.*
import io.ktor.client.engine.js.*
import kotlinx.browser.window
import kotlinx.coroutines.await
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import kotlin.uuid.Uuid
@@ -47,3 +52,26 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
actual suspend fun String.toClipEntry(): ClipEntry =
ClipEntry.withPlainText(this)
@OptIn(ExperimentalWasmJsInterop::class)
@JsFun("(num) => num.toFixed(3)")
private external fun jsFormat(num: Double): String
actual fun format(f: Float): String = jsFormat(f.toDouble())
@Serializable
data class Config(val server: String)
@OptIn(ExperimentalWasmJsInterop::class)
suspend fun loadConfig(): Config {
val resp = window.fetch("config.json").await()
val text = resp.text().await()
return Json.decodeFromString<Config>(text.toString())
}
lateinit var config: Config
@Composable
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) {
constantServerSelect(config.server)(server, onUpdate)
}
@@ -43,13 +43,13 @@ sealed class ProjectEvent {
data class Updated(val changes: HomeResponse.ProjectSummary) : ProjectEvent()
@Serializable
data class NewLabel(val label: LabelResponse) : ProjectEvent()
data class NewVersion(val label: VersionResponse) : ProjectEvent()
@Serializable
data class LabelChanged(val label: LabelResponse) : ProjectEvent()
data class VersionChanged(val label: VersionResponse) : ProjectEvent()
@Serializable
data class LabelDeleted(val id: Uuid) : ProjectEvent()
data class VersionDeleted(val id: Uuid) : ProjectEvent()
@Serializable
data class NewEntry(val entry: EntryResponse) : ProjectEvent()
@@ -1,6 +1,5 @@
package com.jaytux.phoebench.common
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
import kotlin.time.Instant
import kotlin.uuid.Uuid
@@ -23,11 +22,11 @@ data class RefreshRequest(val refreshToken: Uuid)
data class ProjectRequest(val name: String, var isPublic: Boolean)
@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
data class EntryRequest(val label: Uuid, val timestamp: Instant, val projectId: Uuid, val warmups: List<Float>,
val measurements: List<Float>, val unit: TimeUnit)
data class EntryRequest(val version: Uuid, val timestamp: Instant, val projectId: Uuid, val warmups: List<Float>,
val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
@Serializable
data class LogoutRequest(val refresh: Uuid)
@@ -1,14 +1,6 @@
package com.jaytux.phoebench.common
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.serialization.KSerializer
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.uuid.Uuid
@@ -47,14 +39,14 @@ data class HomeResponse(val username: String, val isAdmin: Boolean, val projectL
@Serializable
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
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
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
data class HandshakeResponse(val version: String = ProtocolVersion.VERSION) {
@@ -30,10 +30,10 @@ object Routes {
val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH)
}
object Label {
val new = ApiRoute.post<LabelRequest, LabelResponse>("/label", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialLabelRequest>("/label", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/label", Elevation.AUTH)
object Version {
val new = ApiRoute.post<VersionRequest, VersionResponse>("/version", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialVersionRequest>("/version", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/version", Elevation.AUTH)
}
object Entry {
+2
View File
@@ -22,6 +22,7 @@ koala-plot = "0.12.0"
kolor-picker = "2.1.0"
shadow = "9.3.0"
clikt = "5.0.3"
buildconfig = "6.0.10"
[libraries]
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
@@ -95,3 +96,4 @@ serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
buildconfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildconfig" }
+42 -3
View File
@@ -1,3 +1,5 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
alias(libs.plugins.jvm)
alias(libs.plugins.ktor)
@@ -5,12 +7,27 @@ plugins {
}
group = "com.jaytux.phoebench"
//version = "1.0-SNAPSHOT"
version = rootProject.version
repositories {
mavenCentral()
}
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val generateVersion = tasks.register<Task>("serverVersion") {
doFirst {
val outFile = versionDirectory.get().file("com/jaytux/phoebench/server/Version.kt").asFile
outFile.parentFile.mkdirs()
outFile.writeText("""
package com.jaytux.phoebench.server
object ServerVersion {
const val VERSION="$version"
}
""".trimIndent())
}
}
dependencies {
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
@@ -62,15 +79,37 @@ kotlin {
freeCompilerArgs.add("-Xcontext-parameters")
optIn.add("kotlin.uuid.ExperimentalUuidApi")
}
sourceSets {
val main by getting {
kotlin {
srcDir(versionDirectory)
}
}
}
tasks.compileKotlin {
dependsOn(generateVersion)
}
}
application {
mainClass.set("com.jaytux.phoebench.server.MainKt")
mainClass = "io.ktor.server.netty.EngineMain"
}
tasks.withType<Jar> {
manifest {
attributes["Main-Class"] = application.mainClass
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
duplicatesStrategy = DuplicatesStrategy.WARN
}
ktor {
fatJar {
}
}
tasks.withType<ShadowJar> {
mergeServiceFiles()
append("reference.conf")
}
@@ -10,7 +10,6 @@ import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.autohead.*
import io.ktor.server.plugins.calllogging.*
import io.ktor.server.plugins.contentnegotiation.*
@@ -24,28 +23,16 @@ import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.uuid.Uuid
fun main(args: Array<String>) {
DB.db
EngineMain.main(args)
}
fun Application.module() {
DB.db
println("Entering module for PhoeBench Server version ${ServerVersion.VERSION}...")
install(ContentNegotiation) {
json()
}
install(StatusPages) {
status(HttpStatusCode.Forbidden) { call, status ->
call.respond(status, ErrorResponse("Access Forbidden: CORS failed."))
}
status(HttpStatusCode.Unauthorized) { call, status ->
call.respond(status, ErrorResponse("Unauthorized/unauthenticated."))
}
}
install(SSE) {
//
}
install(SSE) {}
val allowLocalhost = environment.config.propertyOrNull("ktor.cors.enableLocalhostOn")?.getString() ?: "0"
val safeOrigin = environment.config.propertyOrNull("ktor.cors.browserOrigin")?.getString()
@@ -55,6 +42,7 @@ fun Application.module() {
allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowHeader(HttpHeaders.Accept)
allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.ContentType)
exposeHeader(HttpHeaders.ContentType)
@@ -79,6 +67,15 @@ fun Application.module() {
}
}
install(StatusPages) {
status(HttpStatusCode.Forbidden) { call, status ->
call.respond(status, ErrorResponse("Access Forbidden: CORS failed."))
}
status(HttpStatusCode.Unauthorized) { call, status ->
call.respond(status, ErrorResponse("Unauthorized/unauthenticated."))
}
}
install(AutoHeadResponse)
install(CallLogging)
@@ -129,9 +126,9 @@ fun Application.module() {
patchAuth(Routes.Project.update, ProjectHandler::updateProject)
deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject)
postAuth(Routes.Label.new, ProjectHandler::createLabel)
patchAuth(Routes.Label.update, ProjectHandler::updateLabel)
deleteAuth(Routes.Label.delete, ProjectHandler::deleteLabel)
postAuth(Routes.Version.new, ProjectHandler::createVersion)
patchAuth(Routes.Version.update, ProjectHandler::updateVersion)
deleteAuth(Routes.Version.delete, ProjectHandler::deleteVersion)
postAuth(Routes.Entry.new, ProjectHandler::createEntry)
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
@@ -147,22 +144,22 @@ fun Application.module() {
get("{...}") {
println("Fallback for GET ${call.request.path()} triggered.")
call.respond(HttpStatusCode.NotFound, ErrorResponse("Could not find ${call.request.path()}"))
call.respondText(status = HttpStatusCode.NotFound, text = Json.encodeToString(ErrorResponse("Could not find ${call.request.path()}")), contentType = ContentType.Application.Json)
}
post("{...}") {
println("Fallback for POST ${call.request.path()} triggered.")
call.respond(HttpStatusCode.NotFound, ErrorResponse("Could not find ${call.request.path()}"))
call.respondText(status = HttpStatusCode.NotFound, text = Json.encodeToString(ErrorResponse("Could not find ${call.request.path()}")), contentType = ContentType.Application.Json)
}
delete("{...}") {
println("Fallback for DELETE ${call.request.path()} triggered.")
call.respond(HttpStatusCode.NotFound, ErrorResponse("Could not find ${call.request.path()}"))
call.respondText(status = HttpStatusCode.NotFound, text = Json.encodeToString(ErrorResponse("Could not find ${call.request.path()}")), contentType = ContentType.Application.Json)
}
patch("{...}") {
println("Fallback for PATCH ${call.request.path()} triggered.")
call.respond(HttpStatusCode.NotFound, ErrorResponse("Could not find ${call.request.path()}"))
call.respondText(status = HttpStatusCode.NotFound, text = Json.encodeToString(ErrorResponse("Could not find ${call.request.path()}")), contentType = ContentType.Application.Json)
}
}
}
@@ -1,7 +1,6 @@
package com.jaytux.phoebench.server.db
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.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
@@ -16,9 +15,9 @@ object DB {
)
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 {
migration.forEach {
exec(it)
@@ -42,30 +42,33 @@ class Project(id: EntityID<Uuid>) : Entity<Uuid>(id) {
var isPublic by Projects.isPublic
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
}
class Label(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, Label>(Labels)
class Version(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, Version>(Versions)
var label by Labels.label
var color by Labels.color
var projectId by Labels.projectId
var label by Versions.label
var color by Versions.color
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) {
companion object : EntityClass<Uuid, Entry>(Entries)
var projectId by Entries.projectId
var labelId by Entries.labelId
var versionId by Entries.versionId
var timestamp by Entries.timestamp
var warmups by Entries.warmups
var measurements by Entries.measurements
var unit by Entries.unit
var input by Entries.input
var hardware by Entries.hardware
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 color = varchar("color", 7)
val meta = text("meta", eagerLoading = true)
val projectId = reference("project_id", Projects, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
}
object Entries : UuidTable() {
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 warmups = list<Float>("warmups")
val measurements = list<Float>("measurements")
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.HomeEvent
import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.LabelRequest
import com.jaytux.phoebench.common.LabelResponse
import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.VersionResponse
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.PartialVersionRequest
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.ProjectResponse
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.Entry
import com.jaytux.phoebench.server.db.Label
import com.jaytux.phoebench.server.db.Labels
import com.jaytux.phoebench.server.db.Version
import com.jaytux.phoebench.server.db.Versions
import com.jaytux.phoebench.server.db.Project
import com.jaytux.phoebench.server.db.Projects
import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.RouteError.Companion.success
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Transaction
@@ -55,8 +52,8 @@ object ProjectHandler {
context(trns: Transaction)
private fun Project.toResponse(user: User) = ProjectResponse(
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) },
entries.orderBy(Entries.timestamp to SortOrder.ASC).map { EntryResponse(it.id.value, it.label.id.value, it.timestamp, it.warmups, it.measurements, it.unit) })
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.version.id.value, it.timestamp, it.warmups, it.measurements, it.unit, it.input, it.hardware) })
fun home(user: User, req: EmptyRequest) = transaction {
val own = user.projects.orderBy(Projects.name to SortOrder.ASC).map {
@@ -127,49 +124,51 @@ object ProjectHandler {
success(EmptyResponse())
}
fun createLabel(user: User, req: LabelRequest) = transaction {
fun createVersion(user: User, req: VersionRequest) = transaction {
val proj = accessibleProject(user, req.projectId, true)
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
color = req.color
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 {
Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewLabel(res))
Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewVersion(res))
}
success(res)
}
fun updateLabel(user: User, req: Pair<Uuid, PartialLabelRequest>) = transaction {
val lbl = Label.findById(req.first) ?: throw RouteError("Invalid label ID.", HttpStatusCode.NotFound)
lbl.project.isAccessible(user, true)
fun updateVersion(user: User, req: Pair<Uuid, PartialVersionRequest>) = transaction {
val ver = Version.findById(req.first) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
ver.project.isAccessible(user, true)
val changes = req.second
changes.name?.let { lbl.label = it }
changes.name?.let { ver.label = it }
changes.color?.let {
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 {
Buses.projectBus(lbl.projectId.value).sendAll(ProjectEvent.LabelChanged(
LabelResponse(lbl.id.value, lbl.label, lbl.color)
Buses.projectBus(ver.projectId.value).sendAll(ProjectEvent.VersionChanged(
VersionResponse(ver.id.value, ver.label, ver.color, ver.meta)
))
}
success(EmptyResponse())
}
fun deleteLabel(user: User, req: Uuid) = transaction {
val lbl = Label.findById(req) ?: throw RouteError("Invalid label ID.", HttpStatusCode.NotFound)
lbl.project.isAccessible(user, true)
lbl.delete()
fun deleteVersion(user: User, req: Uuid) = transaction {
val ver = Version.findById(req) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
ver.project.isAccessible(user, true)
ver.delete()
ServerScope.launch {
Buses.projectBus(lbl.id.value).sendAll(ProjectEvent.LabelDeleted(req))
Buses.projectBus(ver.id.value).sendAll(ProjectEvent.VersionDeleted(req))
}
success(EmptyResponse())
@@ -178,9 +177,9 @@ object ProjectHandler {
fun createEntry(user: User, req: EntryRequest) = transaction {
val proj = accessibleProject(user, req.projectId, true)
val entry = Entry.new {
label = when(val l = Label.findById(req.label)) {
null -> throw RouteError("Invalid label ID.", HttpStatusCode.NotFound)
is Label if l.projectId.value != proj.id.value -> throw RouteError("Label is attached to a different project.", HttpStatusCode.Conflict)
version = when(val l = Version.findById(req.version)) {
null -> throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
is Version if l.projectId.value != proj.id.value -> throw RouteError("Version is attached to a different project.", HttpStatusCode.Conflict)
else -> l
}
project = proj
@@ -188,9 +187,11 @@ object ProjectHandler {
timestamp = req.timestamp
warmups = req.warmups
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 {
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response))
}
+11 -6
View File
@@ -1,18 +1,23 @@
ktor {
deployment {
port = ${PORT}
port = 8080
port = ${?PORT}
}
application {
modules = [com.jaytux.phoebench.server.MainKt.module]
}
jwt {
secret = ${JWT_SECRET}
issuer = ${JWT_ISSUER}
audience = ${JWT_AUDIENCE}
realm = ${JWT_REALM}
secret = "NO_SECRET_SET!"
secret = ${?JWT_SECRET}
issuer = "http://0.0.0.0"
issuer = ${?JWT_ISSUER}
audience = "http://0.0.0.0"
audience = ${?JWT_AUDIENCE}
realm = "phoebench"
realm = ${?JWT_REALM}
}
cors {
enableLocalhostOn = ${PHOEBENCH_DEV_CLIENT_PORT}
enableLocalhostOn = ${?PHOEBENCH_DEV_CLIENT_PORT}
browserOrigin = ${?PHOEBENCH_SAFE_CLIENT}
}
@@ -0,0 +1 @@
org.slf4j.simpleLogger.log.io.ktor.server.plugins.cors=TRACE