Compare commits
8
Commits
8f4c6cc630
..
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc67adad3
|
||
|
|
8928446444
|
||
|
|
223ec3b886
|
||
|
|
07225ae461
|
||
|
|
462c6da965
|
||
|
|
8494a1a9bb
|
||
|
|
a90b2ebeca
|
||
|
|
60b1a69bc6
|
@@ -47,3 +47,6 @@ bin/
|
||||
*.db
|
||||
.env
|
||||
.idea/
|
||||
artifacts/
|
||||
|
||||
clients/compose/src/wasmJsMain/resources/config.json
|
||||
@@ -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
@@ -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
@@ -12,4 +12,4 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
version = "0.1.0-dev"
|
||||
version = "1.0.0"
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+11
@@ -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
|
||||
}
|
||||
+15
-17
@@ -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()
|
||||
|
||||
+54
-30
@@ -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
|
||||
|
||||
+312
-85
@@ -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())
|
||||
@@ -100,7 +85,7 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
|
||||
}
|
||||
|
||||
Column(Modifier.align(Alignment.BottomEnd).padding(25.dp), horizontalAlignment = Alignment.End) {
|
||||
if(addOpen) {
|
||||
if (addOpen) {
|
||||
var lblWidth by mutableStateOf(1000.dp)
|
||||
var dataWidth by mutableStateOf(1000.dp)
|
||||
val density = LocalDensity.current
|
||||
@@ -133,7 +118,8 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
|
||||
}
|
||||
|
||||
IconButton({ addOpen = !addOpen }, Modifier.scale(1.5f), shape = MaterialTheme.shapes.medium) {
|
||||
Icon(if(addOpen) Lucide.X else Lucide.Plus, if(addOpen) "Add data" else "Close menu")
|
||||
Icon(if (addOpen) Lucide.X else Lucide.Plus, if (addOpen) "Add data" else "Close menu")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,6 +633,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
|
||||
|
||||
+7
@@ -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()
|
||||
}
|
||||
|
||||
+28
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user