13 Commits
39 changed files with 1825 additions and 323 deletions
+6 -1
View File
@@ -45,6 +45,11 @@ bin/
.DS_Store
*.db
*.bak*
.env
.env.*
*.sql
.idea/
artifacts/
artifacts/
clients/compose/src/wasmJsMain/resources/config.json
+62
View File
@@ -0,0 +1,62 @@
# PhoeBench
*Introduction to be done*
## Setup
### Server
The server ZIP files in the releases contain all dependencies, so just unzip it where you want to run the server.
First, set up the environment variables (this cannot currently be done using `.env` files, I think):
- `JWT_AUDIENCE`: the audience to use for the JSON Web Tokens authentication, typically set to your URL;
- `JWT_ISSUER`: the issuer for the authentication tokens, typically also set to your URL;
- `JWT_REALM`: the realm for which the authentication tokens are valid, doesn't matter as there is only one "realm" (I use `phoebench`);
- `JWT_SECRET`: a strong secret key to encrypt the authentication tokens (e.g. generated by `gpg --gen-random --armor 1 128`);
- `PORT`: the port to host the server on;
- `PHOEBENCH_SAFE_CLIENT`: (optional) if you plan on hosting a web client, set this to its URL (for CORS requests);
- `PHOEBENCH_DEV_CLIENT_PORT`: (development only) port to enable CORS requests from `localhost` and `127.0.0.1`.
Next, set up the database variables (recommended through a `.env` file):
- `DATABASE_URL`: JDBC URL to connect to the database;
- `DATABASE_DRIVER`: fully-qualified name of the JDBC driver to connect to the database with;
- `DATABASE_USER`: (if needed) username for the database;
- `DATABASE_PASSWORD`: (if needed) password for the database.
The server currently supports SQLite and MariaDB as databases, using the following settings:
```
# SQLite
DATABASE_URL=jdbc:sqlite:/path/to/db/file.db
DATABASE_DRIVER=org.sqlite.JDBC
# SQLite does not need user/password
# MariaDB
DATABASE_URL=jdbc:mariadb://host:port/database-name
DATABASE_DRIVER=org.mariadb.jdbc.Driver
DATABASE_USER=username
DATABASE_PASSWORD=pass
```
For SQLite, don't forget to substitute `/path/to/db/file.db` (with the path to where the database should be stored).
For MariaDB, don't forget to substitute `host`, `port`, `database-name`, `username`, and `pass` (`host:port` will typically be `localhost:3306`).
To use MariaDB, you will need to create the database and user through its administration interface.
### JVM Client
The JVM client does not require any additional setup.
Simply unzip the client ZIP for your platform (currently only built for Linux), and run `bin/com.jaytux.phoebench.clients` from the unzip location.
However, to avoid having to log in again every time you start the app, it is recommended to ensure you have a working keyring set up.
### WASM Client
The WASM/JS client ZIP contains files that can be statically served, including all dependencies.
As the WASM files can take some time to load over the network, it is recommended to modify the `index.html` file to contain some sort of loading screen.
As for setup - you need to create a file called `config.json` with the following content:
```json
{
"server": "server url"
}
```
Replace `server url` with the URL of the server to connect to (don't forget to add it as `PHOEBENCH_SAFE_CLIENT` to the server, see above).
For deployment, this file should be in the same directory where the ZIP was extracted.
For development, it should be under `/clients/compose/src/wasmJsMain/resources/` to ensure correct loading.
### CLI Client
The CLI client does not require any additional setup, simply use Java to run the JAR: `java -jar phoebench-cli.jar`.
To use the CLI client in scripts, it is recommended to suppress certain warnings: `java --enable-native-access=ALL_UNNAMED -jar phoebench-cli.jar`, as it will otherwise output warning messages that might trip up your scripts.
+2
View File
@@ -2,6 +2,8 @@
set -e
export PHOEBENCH_BUILD_RELEASE='true'
./gradlew :clients:cli:shadowJar
./gradlew :clients:compose:createDistributable
./gradlew :clients:compose:wasmJsBrowserProductionWebpack
+3 -1
View File
@@ -1,3 +1,5 @@
import PhoebenchVersion
plugins {
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
@@ -12,4 +14,4 @@ repositories {
mavenCentral()
}
version = "1.0.0-dev"
version = PhoebenchVersion(1, 1, 0, "")
+7
View File
@@ -0,0 +1,7 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
@@ -0,0 +1,14 @@
data class PhoebenchVersion(val major: Int, val minor: Int, val patch: Int, val note: String = "") {
operator fun compareTo(other: PhoebenchVersion): Int {
// this > other -> return >0
val majorComp = major - other.major
if(majorComp != 0) return majorComp
val minorComp = minor - other.minor
if(minorComp != 0) return minorComp
return patch - other.patch
}
override fun toString(): String = if(note.isNotBlank()) "$major.$minor.$patch-$note" else safeString()
fun safeString() = "$major.$minor.$patch"
}
+31 -1
View File
@@ -8,7 +8,25 @@ plugins {
}
group = "com.jaytux.phoebench"
version = rootProject.version.toString()
version = PhoebenchVersion(1, 1, 1)
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("CLI Client version must be at least as high as protocol/common version")
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"))
@@ -42,6 +60,18 @@ 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> {
@@ -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) {
@@ -96,10 +101,11 @@ object CLI {
override fun nestedCommands(): Array<CliktCommand> = fromReflection()
sealed interface IProjectIdentification
sealed interface ILabelIdentification
sealed interface IVersionIdentification
sealed interface IBenchmarkIdentification
data class ProjectName(val user: String, val project: String) : IProjectIdentification
data class VersionName(val name: String): ILabelIdentification
data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification
data class Name(val name: String): IVersionIdentification, IBenchmarkIdentification
data class ID(val id: Uuid) : IProjectIdentification, IVersionIdentification, IBenchmarkIdentification
sealed interface IData<T> {
fun toList(): List<T>
@@ -179,12 +185,24 @@ object CLI {
override fun run() = ProjectHandlers.newVersion(name, color, parent.finder, meta)
}
@Suppress("unused")
class AddBenchmark : CliktCommand(name = "add-benchmark") {
val parent by requireObject<Project>()
val name by option("--name", help = "Set the version's name (*)")
override fun run() = ProjectHandlers.newBenchmark(name, parent.finder)
}
@Suppress("unused")
class AddData : CliktCommand(name = "add-data") {
val parent by requireObject<Project>()
val version by mutuallyExclusiveOptions<ILabelIdentification>(
val version by mutuallyExclusiveOptions<IVersionIdentification>(
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) }
option("--version", help = "Set the version by name.").convert { Name(it) }
).single()
val benchmark by mutuallyExclusiveOptions<IBenchmarkIdentification>(
option("--benchmark-id", "--bench-id", help = "Set the benchmark by UUID.").convert { ID(Uuid.parse(it)) },
option("--benchmark", "--bench", help = "Set the benchmark by name.").convert { Name(it) }
).single()
val warmup by mutuallyExclusiveOptions<IData<Float>>(
option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
@@ -205,7 +223,14 @@ object CLI {
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, version, warmup, measurement, unit, input, hardware)
override fun run() = ProjectHandlers.newData(parent.finder, benchmark, 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,6 +1,7 @@
package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.clients.cli.CLI.Commands.Project.ProjectName
import com.jaytux.phoebench.common.BenchmarkRequest
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.EntryRequest
@@ -29,6 +30,7 @@ object ProjectHandlers {
Client.callRoute(Routes.home, EmptyRequest())
}.fold({ err ->
System.err.println("Could not get projects list: ${err.msg}")
exitProcess(-1)
}) {
System.err.println("Logged in as ${it.username}")
(it.ownProjects + it.publicProjects).toSet().sortedBy { p -> p.name }.forEach { project ->
@@ -65,18 +67,22 @@ object ProjectHandlers {
}
}.fold({
System.err.println("Failed to get project details: ${it.msg}")
exitProcess(-1)
}) {
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.versions.size} versions:")
it.versions.forEach { v ->
println(" - [${v.id}] VERSION ${v.name} (with color ${v.color}); metadata: ${v.meta}")
}
val map = it.usedLabels.associateBy { l -> l.id }
val map = it.versions.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()} " +
"(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})")
println("\n${it.benchmarks.size} benchmarks:")
it.benchmarks.forEach { b ->
println(" - [${b.id}] BENCHMARK ${b.name} (with ${b.entries.size} entries):")
b.entries.forEach { e ->
println(" - [${e.id}] ENTRY versioned ${map[e.versionId]?.name ?: "<invalid version>"} at ${e.timestamp.fmt()} " +
"(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})")
}
}
}
}
@@ -87,6 +93,7 @@ object ProjectHandlers {
Client.callRoute(Routes.Project.new, ProjectRequest(useName, isPublic))
}.fold({
System.err.println("Failed to create project '$useName': ${it.msg}")
exitProcess(-1)
}) {
println("Project ${it.owner.name}/${it.name} [${it.id}] created.")
}
@@ -94,7 +101,7 @@ object ProjectHandlers {
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
@@ -110,9 +117,26 @@ object ProjectHandlers {
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}")
exitProcess(-1)
}) {
println("Label ${it.name} (${it.color}) [${it.id}] created.")
println("Version ${it.name} (${it.color}) [${it.id}] created.")
}
}
fun newBenchmark(name: String?, project: CLI.Commands.Project.IProjectIdentification?) {
val id = ensureProjectIdentification(project)
val useName = name.maybePrompt("version name") { it }
tryAuthenticated {
id.toId().bind { projectId ->
Client.callRoute(Routes.Benchmark.new, BenchmarkRequest(useName, projectId))
}
}.fold({
System.err.println("Could not create benchmark: ${it.msg}")
exitProcess(-1)
}) {
println("Benchmark ${it.name} [${it.id}] created.")
}
}
@@ -129,14 +153,18 @@ object ProjectHandlers {
fun newData(
project: CLI.Commands.Project.IProjectIdentification?,
label: CLI.Commands.Project.ILabelIdentification?,
benchmark: CLI.Commands.Project.IBenchmarkIdentification?,
label: CLI.Commands.Project.IVersionIdentification?,
warmup: CLI.Commands.Project.IData<Float>?,
measurement: CLI.Commands.Project.IData<Float>?,
unit: TimeUnit?, input: String?, hardware: String?
) {
val projectId = ensureProjectIdentification(project)
val versionId = label.maybePrompt("version name") {
CLI.Commands.Project.VersionName(it)
CLI.Commands.Project.Name(it)
}
val benchmarkId = benchmark.maybePrompt("benchmark name") {
CLI.Commands.Project.Name(it)
}
val warmupData = warmup.ensure("warmup").toList()
val measureData = measurement.ensure("measurements").toList()
@@ -160,19 +188,30 @@ object ProjectHandlers {
tryAuthenticated {
projectId.toId().bind { pId ->
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("Label ${versionId.name} does not exist in this project.").error()
Client.callRoute(Routes.Project.get, pId).bind { p ->
when (versionId) {
is CLI.Commands.Project.ID -> versionId.id.value()
is CLI.Commands.Project.Name -> p.versions.firstOrNull { l -> l.name == versionId.name }?.id?.value()
?: ErrorResponse("Version ${versionId.name} does not exist in this project.").error()
}.bind { vId ->
when (benchmarkId) {
is CLI.Commands.Project.ID -> benchmarkId.id.value()
is CLI.Commands.Project.Name -> p.benchmarks.firstOrNull { b -> b.name == benchmarkId.name }?.id?.value()
?: ErrorResponse("Benchmark ${benchmarkId.name} does not exist in this project.").error()
}.map { it to vId }
}
}.map { pId to it }
}.bind { (pId, vId) ->
}
}.bind { (bId, vId) ->
Client.callRoute(Routes.Entry.new, EntryRequest(
vId, Clock.System.now(), pId,
vId, Clock.System.now(), bId,
warmupData, measureData, timeUnit, useInput, useHardware
))
}
}.fold({
System.err.println("Failed to add data: ${it.msg}")
exitProcess(-1)
}) {
println("Data added successfully.")
}
}
}
+74 -1
View File
@@ -10,6 +10,27 @@ plugins {
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.buildconfig)
}
version = PhoebenchVersion(1, 1, 1)
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("UI Clients version must be at least as high as protocol/common version")
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 +107,20 @@ kotlin {
wasmJsMain.dependencies {
implementation(libs.ktor.client.js)
}
commonMain {
kotlin {
srcDir(versionDirectory)
}
}
}
targets.all {
compilations.all {
compileTaskProvider.configure {
dependsOn(generateVersion)
}
}
}
}
@@ -96,7 +131,7 @@ compose.desktop {
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.jaytux.phoebench.clients"
packageVersion = rootProject.version.toString().split('-')[0]
packageVersion = (version as PhoebenchVersion).safeString()
jvmArgs += listOf("--enable-native-access=ALL-UNNAMED")
includeAllModules = true
@@ -105,4 +140,42 @@ compose.desktop {
}
}
}
}
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
}
}
buildConfig {
generateAtSync = false
useKotlinOutput { internalVisibility = false }
println("Source sets: ${kotlin.sourceSets.toList().map { it.name }}")
sourceSets.named("desktopMain") {
println("Generating 'desktopMain'")
className("PersistenceConstants")
packageName("com.jaytux.phoebench.clients")
envValue("PHOEBENCH_BUILD_RELEASE")?.let {
println("Using release desktop service")
buildConfigField("SERVICE", "com.jaytux.phoebench")
} ?: run {
println("Using debug desktop service")
buildConfigField("SERVICE", "com.jaytux.phoebench.debug")
}
}
}
@@ -1,8 +1,13 @@
package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.platform.ClipEntry
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import kotlinx.serialization.KSerializer
import kotlinx.serialization.serializer
import kotlin.uuid.Uuid
interface IStore {
@@ -20,4 +25,17 @@ expect fun persistentStore(): IStore
expect fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient
expect suspend fun String.toClipEntry(): ClipEntry
expect suspend fun String.toClipEntry(): ClipEntry
expect fun format(f: Float): String
@Composable
expect fun ServerSelect(server: String, onUpdate: (String) -> Unit)
inline fun <reified T> drag(data: T) = drag(data, serializer<T>())
expect fun <T> drag(data: T, serializer: KSerializer<T>): DragAndDropTransferData
inline fun <reified T> drop(data: DragAndDropEvent): T? = drop(data, serializer<T>())
expect fun <T> drop(data: DragAndDropEvent, serializer: KSerializer<T>): T?
@@ -19,6 +19,7 @@ import kotlinx.datetime.format.Padding
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import kotlin.math.absoluteValue
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.random.Random
import kotlin.random.nextInt
@@ -77,17 +78,70 @@ 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
val index = -insertionPoint - 1
return toMutableList().apply { add(index, elem) }
}
inline fun <T, X : Comparable<X>> List<T>.insortIdx(elem: T, crossinline sortBy: (T) -> X): Pair<List<T>, Int?> {
val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy)
if(insertionPoint >= 0) return this to null
val index = -insertionPoint - 1
return toMutableList().apply { add(index, elem) } to index
}
inline fun <T> List<T>.replace(crossinline finder: (T) -> Boolean, crossinline update: (T) -> T): List<T> {
val idx = indexOfFirst { finder(it) }
if(idx == -1) return this
val res = ArrayList(this)
res[idx] = update(res[idx])
return res
}
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
}
fun <T> List<T>.removeAt(idx: Int): List<T> = subList(0, idx) + subList(idx + 1, size)
fun List<Float>.geomean(): Float {
var prod = 1.0f
for(value in this) prod *= value
return prod.pow(1.0f / size.toFloat())
}
@@ -17,6 +17,7 @@ import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.InviteListResponse
import com.jaytux.phoebench.common.Migrate0To1Request
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.UserListResponse
import com.jaytux.phoebench.common.value
@@ -40,11 +41,14 @@ class HomeVM(
private val _users = mutableStateOf(listOf<UserListResponse.UserData>())
private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>())
private val _stepsNeeded = mutableStateOf(listOf<HomeResponse.NeededMigration>())
val username = _username.immutable()
val isAdmin = _isAdmin.immutable()
val projectLimit = _projectLimit.immutable()
val ownProjects = _ownProjects.immutable()
val publicProjects = _publicProjects.immutable()
val stepsNeeded = _stepsNeeded.immutable()
val invites = _invites.immutable()
val users = _users.immutable()
@@ -81,17 +85,22 @@ class HomeVM(
withScope {
resetAdmin()
_repo.getHome().snackOr {
println("Received home response $it")
_username.value = it.username
_isAdmin.value = it.isAdmin
_projectLimit.value = it.projectLimit
_ownProjects.value = it.ownProjects.sortedBy { p -> p.name }
_publicProjects.value = it.publicProjects.sortedBy { p -> p.name }
refreshAdmin()
refreshFrom(it)
}
}
}
private suspend fun refreshFrom(it: HomeResponse) {
println("Received home response $it")
_username.value = it.username
_isAdmin.value = it.isAdmin
_projectLimit.value = it.projectLimit
_ownProjects.value = it.ownProjects.sortedBy { p -> p.name }
_publicProjects.value = it.publicProjects.sortedBy { p -> p.name }
_stepsNeeded.value = it.neededMigrations
refreshAdmin()
}
fun refreshAdmin() {
if(_isAdmin.value) {
_adminJob = withScope {
@@ -212,4 +221,12 @@ class HomeVM(
}
}
}
fun migrate(v0to1: List<Migrate0To1Request.MigrateProject>) {
withScope {
_repo.migrate0To1(v0to1).snackOr {
refreshFrom(it)
}
}
}
}
@@ -9,6 +9,7 @@ import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.InviteListResponse
import com.jaytux.phoebench.common.InviteRequest
import com.jaytux.phoebench.common.LogoutRequest
import com.jaytux.phoebench.common.Migrate0To1Request
import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.UserListResponse
@@ -31,6 +32,8 @@ interface IHomeRepo {
suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit>
suspend fun migrate0To1(conversions: List<Migrate0To1Request.MigrateProject>): Either<ErrorResponse, HomeResponse>
companion object {
class Default(private val _client: Client) : IHomeRepo {
override suspend fun getHome(): Either<ErrorResponse, HomeResponse> =
@@ -62,6 +65,9 @@ interface IHomeRepo {
override suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.new, ProjectRequest(name, isPublic)).ignoreValue()
override suspend fun migrate0To1(conversions: List<Migrate0To1Request.MigrateProject>): Either<ErrorResponse, HomeResponse> =
_client.callRoute(Routes.migrate0To1, Migrate0To1Request(conversions))
}
fun default(client: Client) = Default(client)
@@ -4,10 +4,13 @@ import androidx.compose.ui.graphics.Color
import com.jaytux.phoebench.clients.Client
import com.jaytux.phoebench.clients.hexString
import com.jaytux.phoebench.clients.ignoreValue
import com.jaytux.phoebench.common.BenchmarkRequest
import com.jaytux.phoebench.common.BenchmarkResponse
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.PartialBenchmarkRequest
import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.PartialProjectRequest
@@ -23,11 +26,15 @@ interface IProjectRepo {
suspend fun update(name: String? = null, isPublic: Boolean? = null): Either<ErrorResponse, Unit>
suspend fun delete(): Either<ErrorResponse, Unit>
suspend fun newBenchmark(name: String): Either<ErrorResponse, BenchmarkResponse>
suspend fun updateBenchmark(id: Uuid, name: String? = null): Either<ErrorResponse, Unit>
suspend fun deleteBenchmark(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 deleteLabel(id: Uuid): Either<ErrorResponse, Unit>
suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit>
suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
suspend fun newEntry(benchmark: Uuid, label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
unit: TimeUnit, input: String, hardware: String): Either<ErrorResponse, EntryResponse>
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
@@ -42,19 +49,28 @@ interface IProjectRepo {
override suspend fun delete(): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.delete, _projectId).ignoreValue()
override suspend fun newBenchmark(name: String): Either<ErrorResponse, BenchmarkResponse> =
_client.callRoute(Routes.Benchmark.new, BenchmarkRequest(name, _projectId))
override suspend fun updateBenchmark(id: Uuid, name: String?): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Benchmark.update, id to PartialBenchmarkRequest(name = name)).ignoreValue()
override suspend fun deleteBenchmark(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Benchmark.delete, id).ignoreValue()
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 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> =
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>,
override suspend fun newEntry(benchmark: Uuid, label: Uuid, timestamp: Instant, warmups: List<Float>,
measurements: List<Float>, unit: TimeUnit, input: String, hardware: String
): Either<ErrorResponse, EntryResponse> =
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit, input, hardware))
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, benchmark, warmups, measurements, unit, input, hardware))
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Entry.delete, id).ignoreValue()
@@ -11,7 +11,10 @@ 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.insortIdx
import com.jaytux.phoebench.clients.replace
import com.jaytux.phoebench.clients.withScope
import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.ProjectEvent
@@ -57,9 +60,20 @@ class ProjectVM(
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, Version>) = Entry(
it.id, map[it.labelId] ?: Version.invalid, it.timestamp,
inline fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>, addInput: (String) -> Unit, addHardware: (String) -> Unit) = Entry(
it.id, map[it.versionId] ?: Version.invalid, it.timestamp,
it.warmups, it.measurements, it.unit, it.input, it.hardware
).also { addInput(it.input); addHardware(it.hardware) }
}
}
data class Benchmark(val id: Uuid, val name: String, val entries: List<Entry>) {
fun add(entry: Entry) = copy(entries = entries.insort(entry) { it.timeStamp })
fun remove(entry: Uuid) = copy(entries = entries.filter { it.id != entry })
companion object {
inline fun fromResponse(it: BenchmarkResponse, map: Map<Uuid, Version>, addInput: (String) -> Unit, addHardware: (String) -> Unit) = Benchmark(
it.id, it.name, it.entries.map { er -> Entry.fromResponse(er, map, addInput, addHardware) }
)
}
}
@@ -69,7 +83,8 @@ class ProjectVM(
private val _public = mutableStateOf(false)
private val _editable = mutableStateOf(false)
private val _labels = mutableStateOf(mapOf<Uuid, Version>())
private val _entries = mutableStateOf(listOf<Entry>())
private val _benchmarks = mutableStateOf(listOf<Benchmark>())
private val _currentBenchmark = mutableStateOf(0)
private val _inputs = mutableStateOf(setOf<String>())
private val _hardware = mutableStateOf(setOf<String>())
@@ -78,7 +93,8 @@ class ProjectVM(
val public = _public.immutable()
val editable = _editable.immutable()
val versions = _labels.immutable()
val entries = _entries.immutable()
val benchmarks = _benchmarks.immutable()
val currentBenchmark = _currentBenchmark.immutable()
val inputs = _inputs.immutable()
val hardware = _hardware.immutable()
@@ -102,14 +118,10 @@ class ProjectVM(
_owner.value = it.owner.name
_public.value = it.isPublic
_editable.value = it.isEditable
_labels.value = it.usedLabels.associate { l -> l.id to Version.fromResponse(l) }
_labels.value = it.versions.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)
}
_benchmarks.value = it.benchmarks.map { b -> Benchmark.fromResponse(b, _labels.value, localInputs::add, localHardware::add) }
_inputs.value = localInputs
_hardware.value = localHardware
}
@@ -119,13 +131,12 @@ class ProjectVM(
private fun handleProjectEvent(event: ProjectEvent) {
when(event) {
ProjectEvent.Deleted -> back()
is ProjectEvent.EntryDeleted -> _entries.value = _entries.value.filter { it.id != event.id }
is ProjectEvent.EntryDeleted -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmarkId }) { it.remove(event.id) }
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
_benchmarks.value = _benchmarks.value.replace({ it.id == event.entry.benchmarkId }) { it.add(Entry.fromResponse(event.entry, _labels.value,
{ input -> _inputs.value += input }, { hw -> _hardware.value += hw })) }
}
is ProjectEvent.NewVersion -> _labels.value += (event.label.id to Version.fromResponse(event.label))
is ProjectEvent.Updated -> {
@@ -133,6 +144,23 @@ class ProjectVM(
_owner.value = event.changes.owner.name
_public.value = event.changes.isPublic
}
is ProjectEvent.BenchmarkChanged -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmark.id }) { it.copy(name = event.benchmark.name) }
is ProjectEvent.BenchmarkDeleted -> {
val idx = _benchmarks.value.indexOfFirst { it.id == event.id }
if(idx == _currentBenchmark.value) _currentBenchmark.value = 0
else if(idx < _currentBenchmark.value) _currentBenchmark.value -= 1
_benchmarks.value = _benchmarks.value.filter { it.id != event.id }
}
is ProjectEvent.NewBenchmark -> {
val (updated, idx) = _benchmarks.value.insortIdx(
Benchmark.fromResponse(event.benchmark, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw })
) { it.name }
if(idx != null) {
if(idx < _currentBenchmark.value) _currentBenchmark.value += 1
}
_benchmarks.value = updated
}
}
}
@@ -174,33 +202,78 @@ class ProjectVM(
}
}
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: Version, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit, input: String, hardware: String) {
fun mkBenchmark(name: String) {
withScope {
_repo.newEntry(label.id, Clock.System.now(), warmups, measurements, unit, input, hardware).snackOr {
_repo.newBenchmark(name).snackOr { bench ->
if(_job == null) {
_entries.value += Entry.fromResponse(it, _labels.value)
_inputs.value += input
_hardware.value += hardware
val (updated, idx) = _benchmarks.value.insortIdx(
Benchmark.fromResponse(bench, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw })
) { it.name }
_currentBenchmark.value?.let { curr ->
if(idx != null) {
if(idx < curr) _currentBenchmark.value = curr + 1
}
}
_benchmarks.value = updated
}
}
}
}
fun deleteEntry(id: Uuid) {
fun updateBenchmark(id: Uuid, name: String?) {
withScope {
_repo.deleteEntry(id).snackOr {
if(_job == null)
_entries.value = _entries.value.filter { it.id != id }
val old = _benchmarks.value.find { it.id == id } ?: return@withScope
_repo.updateBenchmark(id, name).snackOr {
if(_job == null) {
_benchmarks.value = _benchmarks.value.replace({ it.id == id }) { it.copy(name = name ?: old.name) }
}
}
}
}
fun deleteBenchmark(id: Uuid) {
withScope {
_repo.deleteBenchmark(id).snackOr {
if(_job == null) {
val idx = _benchmarks.value.indexOfFirst { it.id == id }
if(idx == _currentBenchmark.value) _currentBenchmark.value = 0
else if(idx < _currentBenchmark.value) _currentBenchmark.value -= 1
_benchmarks.value = _benchmarks.value.filter { it.id != id }
}
}
}
}
fun mkEntry(label: Version, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit, input: String, hardware: String) {
withScope {
val current = _benchmarks.value.getOrNull(_currentBenchmark.value) ?: return@withScope
_repo.newEntry(current.id, label.id, Clock.System.now(), warmups, measurements, unit, input, hardware).snackOr { entry ->
if(_job == null) {
_benchmarks.value = _benchmarks.value.replace({ it.id == entry.benchmarkId }) { it.add(Entry.fromResponse(entry, _labels.value,
{ input -> _inputs.value += input }, { hw -> _hardware.value += hw })) }
}
}
}
}
fun previousBenchmark() {
_currentBenchmark.value = maxOf(0, minOf(_currentBenchmark.value - 1, _benchmarks.value.size - 1))
}
fun nextBenchmark() {
_currentBenchmark.value = maxOf(0, minOf(_currentBenchmark.value + 1, _benchmarks.value.size - 1))
}
fun selectBenchmark(id: Uuid) {
_currentBenchmark.value = maxOf(_benchmarks.value.indexOfFirst { it.id == id }, 0)
}
}
@@ -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
@@ -316,7 +320,19 @@ fun ConfirmLogoutDialog(onCancel: () -> Unit, onLogout: (everywhere: Boolean) ->
@Composable
fun HomeView(onSelectProject: (Uuid) -> Unit) {
val vm = viewModel { HomeVM() }
val todo by vm.stepsNeeded
todo.firstOrNull()?.let {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (it) {
is HomeResponse.NeededMigration.Migrate0To1 -> Migrate0To1View(vm, it)
}
}
} ?: HomePanel(vm, onSelectProject)
}
@Composable
fun HomePanel(vm: HomeVM, onSelectProject: (Uuid) -> Unit) {
val auth = AuthProvider.get()
val username by vm.username
val isAdmin by vm.isAdmin
@@ -0,0 +1,241 @@
package com.jaytux.phoebench.clients.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.draganddrop.dragAndDropSource
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.composables.icons.lucide.Cross
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.X
import com.jaytux.phoebench.clients.app
import com.jaytux.phoebench.clients.data.HomeVM
import com.jaytux.phoebench.clients.drag
import com.jaytux.phoebench.clients.drop
import com.jaytux.phoebench.clients.removeAt
import com.jaytux.phoebench.clients.replace
import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.Migrate0To1Request
@Composable
fun Migrate0To1View(vm: HomeVM, input: HomeResponse.NeededMigration.Migrate0To1) {
var isAdding by remember { mutableStateOf(false) }
var projectsToDo by remember { mutableStateOf(input.projects) }
var remapped by remember { mutableStateOf(listOf<Triple<String, Boolean, List<HomeResponse.NeededMigration.Migrate0To1.OldProject>>>()) }
Surface(Modifier.fillMaxSize(0.85f), color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 10.dp, shape = MaterialTheme.shapes.large) {
Column(Modifier.padding(15.dp)) {
Text("Data Migration", style = MaterialTheme.typography.headlineMedium)
Text("The server you connected to has been updated, requiring some decisions from you.")
Text("This new version allows you to organise different benchmarks together in one project. That way, they will share installed versions.")
Text("Please create the new projects you would like to use, and drag the existing projects onto them as benchmarks. You can also drag a benchmark onto an empty space to have it become a top-level project containing a benchmark with the same name.")
Spacer(Modifier.height(10.dp))
Row(Modifier.weight(1f)) {
var showTargets by remember { mutableStateOf(false) }
val globalTarget = remember {
object : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) {
showTargets = true
super.onStarted(event)
}
override fun onEnded(event: DragAndDropEvent) {
showTargets = false
super.onEnded(event)
}
override fun onDrop(event: DragAndDropEvent): Boolean {
val got = drop<HomeResponse.NeededMigration.Migrate0To1.OldProject>(event) ?: return false
println("DROP ONTO global")
projectsToDo = projectsToDo.filter { it.id != got.id }
remapped += got.name to false app listOf(got)
return true
}
}
}
val startMod = if(showTargets) Modifier.border(BorderStroke(3.dp, MaterialTheme.colorScheme.onSurface)) else Modifier
Surface(startMod.weight(0.66f).fillMaxHeight().dragAndDropTarget({ true }, globalTarget), tonalElevation = 10.dp, shape = MaterialTheme.shapes.large) {
Column(Modifier.padding(10.dp)) {
Text("New projects", style = MaterialTheme.typography.headlineSmall)
LazyVerticalGrid(GridCells.Adaptive(minSize = 350.dp), Modifier.weight(1f)) {
itemsIndexed(remapped) { idx, (name, public, old) ->
val target = remember {
object : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) {
showTargets = true
super.onStarted(event)
}
override fun onEnded(event: DragAndDropEvent) {
showTargets = false
super.onEnded(event)
}
override fun onDrop(event: DragAndDropEvent): Boolean {
val got = drop<HomeResponse.NeededMigration.Migrate0To1.OldProject>(event) ?: return false
println("DROP ONTO $name")
projectsToDo = projectsToDo.filter { it.id != got.id }
remapped = remapped.replace({ it.first == name }) { it.first to it.second app (it.third + got) }
return true
}
}
}
val toggle = { public: Boolean ->
remapped = remapped.replace({ it.first == name }) { it.first to public app it.third }
}
Surface(startMod.padding(5.dp).dragAndDropTarget({ true }, target), color = MaterialTheme.colorScheme.tertiaryContainer, tonalElevation = 4.dp, shadowElevation = 1.dp, shape = MaterialTheme.shapes.medium) {
Row(Modifier.padding(5.dp).width(IntrinsicSize.Min)) {
Column(Modifier.weight(1f)) {
Text(name)
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(!public, { toggle(false) }, SegmentedButtonDefaults.itemShape(0, 2)) { Text("Private") }
SegmentedButton(public, { toggle(true) }, SegmentedButtonDefaults.itemShape(1, 2)) { Text("Public") }
}
Column(Modifier.padding(start = 5.dp)) {
old.forEach { Text(it.name, style = MaterialTheme.typography.bodySmall) }
}
}
IconButton({ projectsToDo += old; remapped = remapped.removeAt(idx) }) {
Icon(Lucide.X, "Remove")
}
}
}
}
if(remapped.isNotEmpty()) {
item {
Surface(startMod.padding(5.dp).dragAndDropTarget({ true }, globalTarget), color = MaterialTheme.colorScheme.tertiaryContainer, tonalElevation = 4.dp, shadowElevation = 1.dp, shape = MaterialTheme.shapes.medium) {
Text("Create new...", Modifier.padding(vertical = 15.dp), fontStyle = FontStyle.Italic, color = LocalContentColor.current.copy(alpha = 0.66f))
}
}
}
}
Spacer(Modifier.height(10.dp))
Row {
Box(Modifier.weight(1f))
IconButton({ isAdding = true }) {
Icon(Lucide.Plus, "Add project")
}
}
}
}
Spacer(Modifier.width(10.dp))
Surface(Modifier.weight(0.33f).fillMaxHeight(), tonalElevation = 10.dp, shape = MaterialTheme.shapes.large) {
LazyColumn(Modifier.padding(10.dp).fillMaxHeight()) {
stickyHeader {
Text("Existing projects", style = MaterialTheme.typography.headlineSmall)
}
items(projectsToDo) {
Column {
Surface(Modifier.padding(5.dp).fillMaxWidth().dragAndDropSource { _ -> drag(it) }, tonalElevation = 15.dp, shape = MaterialTheme.shapes.medium) {
Text("${it.name} (${it.versions.size} versions)", Modifier.padding(10.dp))
}
Spacer(Modifier.height(10.dp))
}
}
}
}
}
Spacer(Modifier.height(10.dp))
Row {
Box(Modifier.weight(1f))
Button({ vm.migrate(remapped.map { (name, public, old) -> Migrate0To1Request.MigrateProject(name, public, old.map { it.id }) }) },
enabled = projectsToDo.isEmpty()) {
Text("Perform migration")
}
}
}
}
if(isAdding) AddProjectDialog({ isAdding = false }) { name, public ->
remapped += name to public app listOf()
isAdding = false
}
}
@Composable
fun AddProjectDialog(onCancel: () -> Unit, onAdd: (name: String, public: Boolean) -> Unit) {
Dialog(onDismissRequest = onCancel) {
var name by remember { mutableStateOf("") }
var public by remember { mutableStateOf(false) }
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
Text("Create new project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
Spacer(Modifier.height(10.dp))
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(!public, { public = false }, SegmentedButtonDefaults.itemShape(0, 2)) { Text("Private") }
SegmentedButton(public, { public = true }, SegmentedButtonDefaults.itemShape(1, 2)) { Text("Public") }
}
Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, {
onAdd(name.trim(), public)
}, confirmText = "Create", canConfirm = name.trim().isNotBlank())
}
}
}
}
@@ -25,33 +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.*
import com.jaytux.phoebench.clients.data.ProjectVM
import com.jaytux.phoebench.clients.data.mutableStateSetFrom
import com.jaytux.phoebench.clients.data.mutableStateSetOf
import com.jaytux.phoebench.clients.dualLerp
import com.jaytux.phoebench.clients.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.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.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
@@ -65,13 +50,13 @@ 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.versions
val versions by vm.versions
val benchmarks by vm.benchmarks
val currentBenchmark by vm.currentBenchmark
var editing by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(false) }
var addOpen by remember { mutableStateOf(false) }
var addingLabel by remember { mutableStateOf(false) }
var addingData by remember { mutableStateOf(false) }
var addingBenchmark by remember { mutableStateOf(false) }
Column(Modifier.padding(all = 15.dp)) {
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
@@ -88,51 +73,23 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
}
owner?.let { Text("${if(public) "Public" else "Private"} project by user $it") }
Spacer(Modifier.height(15.dp))
Surface(Modifier.fillMaxSize().padding(8.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) {
Box(Modifier.fillMaxSize().padding(15.dp)) {
ProjectPlotArea(vm)
benchmarks.getOrNull(currentBenchmark)?.let { curr ->
ProjectPanel(vm, editable, versions, benchmarks, currentBenchmark, curr) { addingBenchmark = true }
} ?: run {
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
Box(Modifier.fillMaxSize()) {
Text("No benchmarks yet...", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
if(addOpen) {
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
Box(Modifier.clickable { addOpen = false }.fillMaxSize())
}
}
Column(Modifier.align(Alignment.BottomEnd).padding(25.dp), horizontalAlignment = Alignment.End) {
if(addOpen) {
var lblWidth by mutableStateOf(1000.dp)
var dataWidth by mutableStateOf(1000.dp)
val density = LocalDensity.current
Column(Modifier.width(maxOf(lblWidth, dataWidth)), horizontalAlignment = Alignment.End) {
Surface(Modifier.onGloballyPositioned {
lblWidth = with(density) { it.size.width.toDp() }
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
Row(Modifier.clickable { addingLabel = true }.padding(15.dp)) {
Icon(Lucide.CaseSensitive, "Add label")
Spacer(Modifier.width(5.dp))
Text("Label")
}
}
Spacer(Modifier.height(10.dp))
Surface(Modifier.onGloballyPositioned {
dataWidth = with(density) { it.size.width.toDp() }
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
Row(Modifier.clickable { addingData = true }.padding(15.dp)) {
Icon(Lucide.ChartLine, "Add data")
Spacer(Modifier.width(5.dp))
Text("Data")
}
}
Spacer(Modifier.height(15.dp))
if(addingBenchmark) {
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
Box(Modifier.clickable { addingBenchmark = false }.fillMaxSize())
}
}
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")
Box(Modifier.align(Alignment.BottomEnd).padding(25.dp)) {
IconButton({ addingBenchmark = true }, Modifier.scale(1.5f), shape = MaterialTheme.shapes.medium) {
Icon(Lucide.Plus, "Add benchmark")
}
}
}
}
@@ -147,6 +104,117 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
vm.delete()
}
if(addingBenchmark) AddBenchmarkDialog({ addingBenchmark = false }) {
vm.mkBenchmark(it)
addingBenchmark = false
}
}
@Composable
fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM.Version>, benchmarks: List<ProjectVM.Benchmark>, currentIndex: Int, currentBenchmark: ProjectVM.Benchmark, onAddBenchmark: () -> Unit) {
var addOpen by remember { mutableStateOf(false) }
var addingLabel by remember { mutableStateOf(false) }
var addingData by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf(false) }
Surface(Modifier.fillMaxSize().padding(8.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) {
Column(Modifier.fillMaxSize()) {
Surface(Modifier.fillMaxWidth(), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) {
Row(Modifier.padding(15.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton({ vm.previousBenchmark() }, enabled = currentIndex > 0) {
Icon(Lucide.ChevronLeft, "Previous benchmark")
}
Box(Modifier.weight(1f)) {
var isOpen by remember { mutableStateOf(false) }
Surface(shape = MaterialTheme.shapes.medium) {
Row(Modifier.clickable { isOpen = !isOpen }, verticalAlignment = Alignment.CenterVertically) {
Text(currentBenchmark.name, style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.width(10.dp))
IconButton({ editing = true }) {
Icon(Lucide.Pencil, "Edit benchmark")
}
Icon(Lucide.ChevronDown, "Select benchmark...")
}
}
DropdownMenu(isOpen, { isOpen = false }) {
benchmarks.forEach { b ->
DropdownMenuItem({ Text(b.name, style = MaterialTheme.typography.headlineSmall) }, { vm.selectBenchmark(b.id); isOpen = false })
}
}
}
IconButton({ vm.nextBenchmark() }, enabled = currentIndex < benchmarks.size - 1) {
Icon(Lucide.ChevronRight, "Previous benchmark")
}
}
}
Box(Modifier.fillMaxSize().padding(15.dp)) {
ProjectPlotArea(vm, currentBenchmark)
if(editable) {
if(addOpen) {
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
Box(Modifier.clickable { addOpen = false }.fillMaxSize())
}
}
Column(Modifier.align(Alignment.BottomEnd).padding(25.dp), horizontalAlignment = Alignment.End) {
if (addOpen) {
var lblWidth by mutableStateOf(1000.dp)
var dataWidth by mutableStateOf(1000.dp)
var benchWidth by mutableStateOf(1000.dp)
val density = LocalDensity.current
Column(Modifier.width(maxOf(lblWidth, dataWidth, benchWidth)), horizontalAlignment = Alignment.End) {
Surface(Modifier.onGloballyPositioned {
benchWidth = with(density) { it.size.width.toDp() }
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
Row(Modifier.clickable(onClick = onAddBenchmark).padding(15.dp)) {
Icon(Lucide.TestTubeDiagonal, "Add benchmark")
Spacer(Modifier.width(5.dp))
Text("Benchmark")
}
}
Spacer(Modifier.height(15.dp))
Surface(Modifier.onGloballyPositioned {
lblWidth = with(density) { it.size.width.toDp() }
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
Row(Modifier.clickable { addingLabel = true }.padding(15.dp)) {
Icon(Lucide.CaseSensitive, "Add label")
Spacer(Modifier.width(5.dp))
Text("Label")
}
}
Spacer(Modifier.height(10.dp))
Surface(Modifier.onGloballyPositioned {
dataWidth = with(density) { it.size.width.toDp() }
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
Row(Modifier.clickable { addingData = true }.padding(15.dp)) {
Icon(Lucide.ChartLine, "Add data")
Spacer(Modifier.width(5.dp))
Text("Data")
}
}
Spacer(Modifier.height(15.dp))
}
}
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")
}
}
}
}
}
}
if(addingLabel) AddVersionDialog({ addingLabel = false; addOpen = false }) { name, color, meta ->
vm.mkLabel(name, color, meta)
addOpen = false
@@ -155,13 +223,15 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
val usedInputs by vm.inputs
val usedHardware by vm.hardware
if(addingData) AddEntryDialog(labels, usedInputs, usedHardware,
if(addingData) AddEntryDialog(versions, usedInputs, usedHardware,
onCancel = { addingData = false; addOpen = false },
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
}
if(editing) EditBenchmarkDialog(currentBenchmark, { editing = false }) { vm.updateBenchmark(currentBenchmark.id, it) }
}
@Composable
@@ -172,7 +242,7 @@ fun ProjectEditDialog(name: String, public: Boolean, onCancel: () -> Unit, onSav
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
Text("Create project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
Text("Modify project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(updName, { updName = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
Spacer(Modifier.height(10.dp))
Row(Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically) {
@@ -206,6 +276,44 @@ fun ConfirmDeleteProjectDialog(name: String, onCancel: () -> Unit, onDelete: ()
}
}
@Composable
fun AddBenchmarkDialog(onCancel: () -> Unit, onAdd: (name: String) -> Unit) {
Dialog(onDismissRequest = onCancel) {
var name 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 benchmark", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, {
onAdd(name.trim())
}, confirmText = "Create", canConfirm = name.trim().isNotBlank())
}
}
}
}
@Composable
fun EditBenchmarkDialog(benchmark: ProjectVM.Benchmark, onCancel: () -> Unit, onUpdate: (name: String) -> Unit) {
Dialog(onDismissRequest = onCancel) {
var name by remember { mutableStateOf(benchmark.name) }
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
Text("Modify benchmark", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, {
onUpdate(name.trim())
}, confirmText = "Update", canConfirm = name.trim().isNotBlank() && name.trim() != benchmark.name)
}
}
}
}
@Composable
fun AddVersionDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color, meta: String) -> Unit) {
Dialog(onDismissRequest = onCancel) {
@@ -225,7 +333,7 @@ fun AddVersionDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color, m
CancelConfirmXRow(onCancel, {
onAdd(name.trim(), color, meta.trim())
}, confirmText = "Create", canConfirm = name.trim().isNotBlank() && meta.trim().isNotBlank())
}, confirmText = "Create", canConfirm = name.trim().isNotBlank())
}
}
}
@@ -248,8 +356,11 @@ fun QuickVersion(lbl: ProjectVM.Version, hollow: Boolean = false) {
color = LocalContentColor.current.copy(alpha = 0.75f),
)
}
Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(0.75f))
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))
}
}
}
@@ -313,7 +424,7 @@ fun AddEntryDialog(
DropdownMenuItem({
Text(
"Create new label...",
"Create new version...",
color = LocalContentColor.current.copy(alpha = 0.75f),
fontStyle = FontStyle.Italic
)
@@ -390,10 +501,73 @@ fun AddEntryDialog(
}
@Composable
fun ProjectPlotArea(vm: ProjectVM){
val entries by vm.entries
fun EditVersionDialog(current: ProjectVM.Version, onCancel: () -> Unit, onUpdate: (id: Uuid, name: String?, color: Color?, meta: String?) -> Unit) {
Dialog(onDismissRequest = onCancel) {
var name by remember { mutableStateOf(current.name) }
var color by remember { mutableStateOf(current.uiColor) }
var meta by remember { mutableStateOf(current.meta) }
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
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, {
val useName = name.trim() nonEq current.name
val useColor = color nonEq current.uiColor
val useMeta = meta.trim() nonEq current.meta
onUpdate(current.id, useName, useColor, useMeta)
}, confirmText = "Update", canConfirm = name.trim().isNotBlank())
}
}
}
}
@Composable
fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
val entries = benchmark.entries
val versions by vm.versions
val editable by vm.editable
val allInputs by vm.inputs
val allHardware by vm.hardware
val visibleInputs by remember(entries, allInputs) { mutableStateOf(allInputs intersect entries.map { it.input }.toSet()) }
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 inputFilter = remember(allInputs) { mutableStateSetFrom(allInputs) }
var filterHardware by remember { mutableStateOf<String?>(null) }
val versionFilterRev by versionFilter.revision
val inputFilterRev by inputFilter.revision
val enabledHardware = remember(allHardware) { mutableStateSetOf<String>() }
val enabledInputs = remember(allInputs) { mutableStateSetOf<String>() }
LaunchedEffect(entries, allInputs, allHardware, timeFilter, timeMin, timeMax, versionFilterRev, inputFilterRev, filterHardware) {
val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
enabledHardware.clear()
enabledInputs.clear()
filteredEntries = entries.filter { entry ->
if(entry.label !in versionFilter) return@filter false
if(!(entry.timeStamp inRange timeRange)) return@filter false
if(entry.input !in inputFilter) return@filter false
if(filterHardware != null && entry.hardware != filterHardware) return@filter false
enabledHardware.add(entry.hardware)
enabledInputs.add(entry.input)
true
}
}
var displayWarmups by remember { mutableStateOf(false) }
var currentUnit by remember { mutableStateOf(TimeUnit.SECONDS) }
@@ -402,42 +576,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 versionFilter = remember(versions) { mutableStateSetFrom(versions.values) }
val labelVersionFilter by versionFilter.revision
val allInputs by vm.inputs
val allHardware by vm.hardware
val enabledInputs = remember { mutableStateSetOf<String>() }
val enabledHardware = remember { mutableStateSetOf<String>() }
var filterInput by remember { mutableStateOf<String?>(null) }
var filterHardware by remember { mutableStateOf<String?>(null) }
LaunchedEffect(entries, displayWarmups, currentUnit, timeFilter, timeMin, timeMax, labelVersionFilter, filterInput, filterHardware) {
LaunchedEffect(filteredEntries, displayWarmups, currentUnit) {
var maxX = 0f
var minY = 0f
var maxY = 0f
val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
enabledInputs.clear()
enabledHardware.clear()
renderableEntries = entries.mapNotNull { entry ->
renderableEntries = filteredEntries.map { entry ->
val use = if(displayWarmups) entry.warmups else entry.measurements
if(entry.label !in versionFilter) return@mapNotNull null
if(!(entry.timeStamp inRange timeRange)) return@mapNotNull null
if(filterInput != null && entry.input != filterInput) return@mapNotNull null
if(filterHardware != null && entry.hardware != filterHardware) return@mapNotNull null
maxX = maxOf(maxX, use.size.toFloat())
enabledInputs.add(entry.input)
enabledHardware.add(entry.hardware)
use.mapIndexed { idx, it ->
val converted = entry.nativeUnit.convertTo(currentUnit, it)
minY = minOf(minY, converted)
@@ -451,6 +597,13 @@ fun ProjectPlotArea(vm: ProjectVM){
yMax = maxY
}
var deletingVersion by remember { mutableStateOf<ProjectVM.Version?>(null) }
var editingVersion 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
@@ -460,8 +613,51 @@ 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 ->
class MeanAccumulator(var sum: Float = 0.0f, var count: Float = 0.0f) {
fun add(f: Float) { sum += f; count += 1.0f }
fun mean() = if(count == 0.0f) 0.0f else sum / count
}
val perVersion = mutableMapOf<ProjectVM.Version, MutableMap<String, MeanAccumulator>>()
filteredEntries.forEach { entry ->
val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum()
perVersion.getOrPut(entry.label) { mutableMapOf() }
.getOrPut(entry.input) { MeanAccumulator() }
.add(corrected)
}
val subMeans = perVersion.mapValues { (_, v) -> v.mapValues { (_, acc) -> acc.mean() } }
val baseline = subMeans[bl] ?: return@let null
val versionSpeedups = subMeans.mapValues { (_, v) ->
v.mapNotNull { (input, mean) ->
val base = baseline[input] ?: return@mapNotNull null
(base / mean) to mean
}
}
versionSpeedups.mapNotNull { (version, inputMeans) ->
val speedup = inputMeans.map { it.first }.geomean()
max = maxOf(max, speedup)
version.name to speedup app version.uiColor app inputMeans.map { it.second }.geomean()
}.sortedBy { it.t1 }
}
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)
@@ -518,24 +714,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
),
@@ -552,40 +740,65 @@ fun ProjectPlotArea(vm: ProjectVM){
Spacer(Modifier.width(20.dp))
Row(Modifier.weight(0.33f).fillMaxHeight()) {
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(versions.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
Text("No versions yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
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(versions.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
Text("No versions yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
}
}
}
else {
LazyColumn(Modifier.padding(start = 5.dp)) {
items(versions.toList().sortedBy { it.second.name }) { (_, lbl) ->
Box(Modifier.fillMaxWidth().clickable {
if(versionFilter.size == versions.size) {
versionFilter.clear()
versionFilter.add(lbl)
}
else {
versionFilter.toggle(lbl)
if(versionFilter.isEmpty()) versionFilter.addAll(versions.values)
}
}) {
Box {
QuickVersion(lbl, lbl !in versionFilter)
else {
LazyColumn(Modifier.padding(start = 5.dp)) {
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 versionFilter) {
Box(Modifier.matchParentSize()) {
HorizontalDivider(
Modifier.fillMaxWidth().align(Alignment.Center),
thickness = 3.dp,
color = LocalContentColor.current
)
if (lbl !in versionFilter) {
Box(Modifier.matchParentSize()) {
HorizontalDivider(
Modifier.fillMaxWidth().align(Alignment.Center),
thickness = 3.dp,
color = LocalContentColor.current
)
}
}
}
if(editable) {
IconButton({ editingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) {
Icon(Lucide.Pencil, "Edit version")
}
}
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")
}
}
}
}
@@ -593,48 +806,143 @@ 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((visibleInputs intersect inputFilter.toSet()).sorted()) {
Row(Modifier.fillMaxWidth().clickable {
if(inputFilter.size == allInputs.size) {
inputFilter.clear()
inputFilter.add(it)
}
else if(inputFilter.size == 1) {
inputFilter.addAll(allInputs.toSet()); inputFilter.remove(it)
}
else {
inputFilter.toggle(it)
if(inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
}
}) {
Text(it, Modifier.padding(start = 5.dp), color = LocalContentColor.current.copy(alpha = if(it in enabledInputs) 1.0f else 0.9f))
if(it !in enabledInputs) Text(" (no data)", Modifier.align(Alignment.Bottom),
style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(0.9f))
}
}
items((visibleInputs - inputFilter.toSet()).sorted()) {
Row(Modifier.fillMaxWidth().clickable {
if(inputFilter.size == allInputs.size) {
inputFilter.clear()
inputFilter.add(it)
}
else {
inputFilter.toggle(it)
if(inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
}
}) {
Text(it, Modifier.padding(start = 5.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))
}
}
}
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)) }
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))
items((allInputs intersect enabledInputs.toSet()).toList()) {
Row(Modifier.fillMaxWidth().clickable { filterInput = if(filterInput == null) it else null }) {
Text(it, Modifier.padding(start = 5.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))
}
}
}
}
}
}
}
items((allInputs - enabledInputs.toSet()).toList()) {
Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f))
}
deletingVersion?.let { rmV ->
ConfirmDeleteVersionDialog(rmV, { deletingVersion = null }) {
vm.deleteVersion(rmV.id)
}
}
item { Spacer(Modifier.height(10.dp)) }
item { HorizontalDivider(Modifier.height(1.dp)) }
item { Spacer(Modifier.height(10.dp)) }
editingVersion?.let { edV ->
EditVersionDialog(edV, { editingVersion = null }) { id, name, color, meta ->
vm.updateLabel(id, name, color, meta)
editingVersion = null
}
}
}
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))
}
@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")
}
}
}
@@ -1,19 +1,27 @@
package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTransferAction
import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.draganddrop.DragAndDropTransferable
import androidx.compose.ui.draganddrop.awtTransferable
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
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import kotlin.uuid.Uuid
object JVMStore : IStore {
private val json = Json
const val SERVICE = "com.jaytux.phoebench"
const val SERVICE = PersistenceConstants.SERVICE
private class StoredProperty<T>(
val key: String,
@@ -55,4 +63,22 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
@OptIn(ExperimentalComposeUiApi::class)
actual suspend fun String.toClipEntry(): ClipEntry =
ClipEntry(StringSelection(this))
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)
@OptIn(ExperimentalComposeUiApi::class)
actual fun <T> drag(data: T, serializer: KSerializer<T>): DragAndDropTransferData =
DragAndDropTransferData(
DragAndDropTransferable(StringSelection(Json.encodeToString(serializer, data))),
supportedActions = listOf(DragAndDropTransferAction.Move)
)
@OptIn(ExperimentalComposeUiApi::class)
actual fun <T> drop(data: DragAndDropEvent, serializer: KSerializer<T>): T? =
if(data.awtTransferable.isDataFlavorSupported(DataFlavor.stringFlavor))
Json.decodeFromString(serializer, data.awtTransferable.getTransferData(DataFlavor.stringFlavor) as String)
else null
@@ -1,11 +1,13 @@
package com.jaytux.phoebench.clients
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
suspend fun main() {
config = loadConfig()
ComposeViewport(document.body!!) {
App()
}
@@ -1,12 +1,23 @@
package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.draganddrop.domDataTransferOrNull
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.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import org.w3c.dom.DataTransfer
import kotlin.uuid.Uuid
object WasmJsStore : IStore {
@@ -46,4 +57,42 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
}
actual suspend fun String.toClipEntry(): ClipEntry =
ClipEntry.withPlainText(this)
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)
}
@OptIn(ExperimentalWasmJsInterop::class)
@JsFun("() => new DataTransfer()")
private external fun createDataTransfer(): DataTransfer
@OptIn(ExperimentalComposeUiApi::class)
actual fun <T> drag(data: T, serializer: KSerializer<T>): DragAndDropTransferData = DragAndDropTransferData(
createDataTransfer().also { it.setData("application/json", Json.encodeToString(serializer, data)) }
)
@OptIn(ExperimentalComposeUiApi::class)
actual fun <T> drop(data: DragAndDropEvent, serializer: KSerializer<T>): T? =
data.transferData?.domDataTransferOrNull?.getData("application/json")?.let {
Json.decodeFromString(serializer, it)
}
+3 -1
View File
@@ -11,6 +11,8 @@ val partialsDirectory = layout.buildDirectory.dir("generated/sources/partials")
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val requestsDirectory = layout.projectDirectory.dir("src/commonMain/kotlin/com/jaytux/phoebench/common/")
version = rootProject.version as PhoebenchVersion
val generatePartials = tasks.register<Exec>("generatePartials") {
group = "generation"
description = "Generate Partial classes (requests with all-nullable fields)"
@@ -37,7 +39,7 @@ val generateVersion = tasks.register<Task>("protocolVersion") {
package com.jaytux.phoebench.common
object ProtocolVersion {
const val VERSION="${rootProject.version}"
const val VERSION="${version}"
}
""".trimIndent())
}
@@ -42,6 +42,15 @@ sealed class ProjectEvent {
@Serializable
data class Updated(val changes: HomeResponse.ProjectSummary) : ProjectEvent()
@Serializable
data class NewBenchmark(val benchmark: BenchmarkResponse) : ProjectEvent()
@Serializable
data class BenchmarkChanged(val benchmark: BenchmarkSummary) : ProjectEvent()
@Serializable
data class BenchmarkDeleted(val id: Uuid) : ProjectEvent()
@Serializable
data class NewVersion(val label: VersionResponse) : ProjectEvent()
@@ -55,5 +64,5 @@ sealed class ProjectEvent {
data class NewEntry(val entry: EntryResponse) : ProjectEvent()
@Serializable
data class EntryDeleted(val id: Uuid) : ProjectEvent()
data class EntryDeleted(val id: Uuid, val benchmarkId: Uuid) : ProjectEvent()
}
@@ -19,13 +19,16 @@ class EmptyRequest
data class RefreshRequest(val refreshToken: Uuid)
@Serializable @ToPartialize
data class ProjectRequest(val name: String, var isPublic: Boolean)
data class ProjectRequest(val name: String, val isPublic: Boolean)
@Serializable @ToPartialize
data class VersionRequest(val name: String, val color: String, val meta: String, val projectId: Uuid)
data class BenchmarkRequest(val name: String, val projectId: Uuid)
@Serializable @ToPartialize
data class EntryRequest(val version: Uuid, val timestamp: Instant, val projectId: Uuid, val warmups: List<Float>,
data class VersionRequest(val name: String, val color: String, val meta: String, val benchmarkId: Uuid)
@Serializable @ToPartialize
data class EntryRequest(val version: Uuid, val timestamp: Instant, val benchmarkId: Uuid, val warmups: List<Float>,
val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
@Serializable
@@ -35,4 +38,10 @@ data class LogoutRequest(val refresh: Uuid)
data class UserUpdateRequest(val projectLimit: Int? = null, val isAdmin: Boolean? = null)
@Serializable
data class InviteRequest(val asAdmin: Boolean)
data class InviteRequest(val asAdmin: Boolean)
@Serializable
data class Migrate0To1Request(val conversions: List<MigrateProject>) {
@Serializable
data class MigrateProject(val newProjectName: String, val isPublic: Boolean, val oldProjectsAsBenchmarks: List<Uuid>)
}
@@ -32,20 +32,35 @@ data class UserListResponse(val users: List<UserData>) {
data class NamedID(val name: String, val id: Uuid)
@Serializable
data class HomeResponse(val username: String, val isAdmin: Boolean, val projectLimit: Int, val ownProjects: List<ProjectSummary>, val publicProjects: List<ProjectSummary>) {
data class HomeResponse(val username: String, val isAdmin: Boolean, val projectLimit: Int, val ownProjects: List<ProjectSummary>, val publicProjects: List<ProjectSummary>, val neededMigrations: List<NeededMigration>) {
@Serializable
data class ProjectSummary(val id: Uuid, val name: String, val isPublic: Boolean, val owner: NamedID)
@Serializable
sealed class NeededMigration {
@Serializable
data class Migrate0To1(val projects: List<OldProject>) : NeededMigration() {
@Serializable
data class OldProject(val id: Uuid, val name: String, val versions: List<VersionResponse>)
}
}
}
@Serializable
data class ProjectResponse(val id: Uuid, val name: String, val owner: NamedID, val isPublic: Boolean, val isEditable: Boolean,
val usedLabels: List<VersionResponse>, val entries: List<EntryResponse>)
val benchmarks: List<BenchmarkResponse>, val versions: List<VersionResponse>)
@Serializable
data class BenchmarkSummary(val id: Uuid, val name: String)
@Serializable
data class BenchmarkResponse(val id: Uuid, val name: String, val entries: List<EntryResponse>)
@Serializable
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>,
data class EntryResponse(val id: Uuid, val benchmarkId: Uuid, val versionId: Uuid, val timestamp: Instant, val warmups: List<Float>,
val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
@Serializable
@@ -22,6 +22,7 @@ object Routes {
val handshake = ApiRoute.get<HandshakeResponse>("/", Elevation.UN_AUTH)
val home = ApiRoute.get<HomeResponse>("/home", Elevation.AUTH)
val migrate0To1 = ApiRoute.post<Migrate0To1Request, HomeResponse>("/migrate/0-1", Elevation.AUTH)
object Project {
val new = ApiRoute.post<ProjectRequest, ProjectResponse>("/project", Elevation.AUTH)
@@ -30,6 +31,12 @@ object Routes {
val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH)
}
object Benchmark {
val new = ApiRoute.post<BenchmarkRequest, BenchmarkResponse>("/benchmark", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialBenchmarkRequest>("/benchmark", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/benchmark", Elevation.AUTH)
}
object Version {
val new = ApiRoute.post<VersionRequest, VersionResponse>("/version", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialVersionRequest>("/version", Elevation.AUTH)
+3 -1
View File
@@ -22,6 +22,7 @@ koala-plot = "0.12.0"
kolor-picker = "2.1.0"
shadow = "9.3.0"
clikt = "5.0.3"
buildconfig = "6.0.10"
[libraries]
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
@@ -94,4 +95,5 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref =
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
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" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
buildconfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildconfig" }
+31
View File
@@ -7,11 +7,30 @@ plugins {
}
group = "com.jaytux.phoebench"
version = PhoebenchVersion(1, 1, 2)
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("Server version must be at least as high as protocol/common 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)
@@ -63,6 +82,18 @@ kotlin {
freeCompilerArgs.add("-Xcontext-parameters")
optIn.add("kotlin.uuid.ExperimentalUuidApi")
}
sourceSets {
val main by getting {
kotlin {
srcDir(versionDirectory)
}
}
}
tasks.compileKotlin {
dependsOn(generateVersion)
}
}
application {
@@ -2,6 +2,8 @@ package com.jaytux.phoebench.server
import com.jaytux.phoebench.common.*
import com.jaytux.phoebench.server.db.DB
import com.jaytux.phoebench.server.db.Meta
import com.jaytux.phoebench.server.db.Metas
import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.*
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject
@@ -25,35 +27,33 @@ import kotlin.uuid.Uuid
fun Application.module() {
DB.db
println("Entering module...")
println("Entering module for PhoeBench Server version ${ServerVersion.VERSION}...")
if(Meta.single.revision < Metas.CURRENT_REVISION) {
DB.migrate(Meta.single.revision, Metas.CURRENT_REVISION)
}
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) {
//
}
val allowLocalhost = environment.config.propertyOrNull("ktor.cors.enableLocalhostOn")?.getString() ?: "0"
val safeOrigin = environment.config.propertyOrNull("ktor.cors.browserOrigin")?.getString()
install(CORS) {
// -- methods --
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
// -- headers --
allowHeader(HttpHeaders.Accept)
allowHeader(HttpHeaders.Pragma)
allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.CacheControl)
allowHeader(HttpHeaders.LastEventID)
// -- exposed headers --
exposeHeader(HttpHeaders.ContentType)
allowCredentials = true
allowNonSimpleContentTypes = true
@@ -76,6 +76,17 @@ fun Application.module() {
}
}
install(SSE) {}
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)
@@ -120,12 +131,17 @@ fun Application.module() {
deleteAdmin(Routes.Auth.User.delete, AuthHandler::deleteUser)
getAuth(Routes.home, ProjectHandler::home)
postAuth(Routes.migrate0To1, ProjectHandler::migrate01)
postAuth(Routes.Project.new, ProjectHandler::createProject)
getAuth(Routes.Project.get, ProjectHandler::getProject)
patchAuth(Routes.Project.update, ProjectHandler::updateProject)
deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject)
postAuth(Routes.Benchmark.new, ProjectHandler::createBenchmark)
patchAuth(Routes.Benchmark.update, ProjectHandler::updateBenchmark)
deleteAuth(Routes.Benchmark.delete, ProjectHandler::deleteBenchmark)
postAuth(Routes.Version.new, ProjectHandler::createVersion)
patchAuth(Routes.Version.update, ProjectHandler::updateVersion)
deleteAuth(Routes.Version.delete, ProjectHandler::deleteVersion)
@@ -21,4 +21,6 @@ fun nowPlusDays(days: Int): Instant = nowPlus(days, DateTimeUnit.DAY)
fun nowPlus(time: Int, unit: DateTimeUnit): Instant {
val now = Clock.System.now()
return now.plus(time, unit, systemTZ)
}
}
infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
@@ -1,9 +1,11 @@
package com.jaytux.phoebench.server.db
import com.jaytux.phoebench.server.DotEnv
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
object DB {
@@ -17,7 +19,7 @@ object DB {
transaction {
SchemaUtils.create(Users, Invites, RefreshTokens, Projects, Versions, Entries)
val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Versions, Entries)
val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Versions, Entries, Metas, EntriesV2, Benchmarks)
try {
migration.forEach {
exec(it)
@@ -29,4 +31,29 @@ object DB {
}
}
}
fun migrate(from: Int, to: Int) = when(from) {
0 -> if(to == 1) migrate0To1() else null
else -> null
} ?: throw IllegalArgumentException("Migration $from -> $to is not defined")
private fun migrate0To1() {
transaction {
Users.update(where = { Users.isAdmin eq true }) { it[benchmarkLimit] = -1 }
Users.update(where = { Users.isAdmin eq false }) { it.update(benchmarkLimit, projectLimit) }
}
transaction {
User.all().forEach {
if(it.projects.count() == 0L) {
it.projectVersion = Projects.CURRENT_VERSION
return@forEach
}
}
}
transaction {
Meta.single.revision = 1
}
}
}
@@ -1,10 +1,25 @@
package com.jaytux.phoebench.server.db
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.dao.Entity
import org.jetbrains.exposed.v1.dao.EntityClass
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.uuid.Uuid
class Meta(id: EntityID<Int>) : Entity<Int>(id) {
companion object : EntityClass<Int, Meta>(Metas) {
val single by lazy {
transaction {
val got = all().limit(1).toList()
got.firstOrNull() ?: new {}
}
}
}
var revision by Metas.revision
}
class User(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, User>(Users)
@@ -13,6 +28,8 @@ class User(id: EntityID<Uuid>) : Entity<Uuid>(id) {
var isAdmin by Users.isAdmin
var isOwner by Users.isOwner
var projectLimit by Users.projectLimit
var benchmarkLimit by Users.benchmarkLimit
var projectVersion by Users.projectsVersion
val projects by Project referrersOn Projects.ownerId
val sessions by RefreshToken referrersOn RefreshTokens.userId
@@ -42,8 +59,18 @@ class Project(id: EntityID<Uuid>) : Entity<Uuid>(id) {
var isPublic by Projects.isPublic
var owner by User referencedOn Projects.ownerId
val labels by Version referrersOn Versions.projectId
val entries by Entry referrersOn Entries.projectId
val versions by Version referrersOn Versions.projectId
val benchmarks by Benchmark referrersOn Benchmarks.projectId
}
class Benchmark(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, Benchmark>(Benchmarks)
var projectId by Benchmarks.projectId
var name by Benchmarks.name
val project by Project referencedOn Benchmarks.projectId
val entries by EntryV2 referrersOn EntriesV2.benchmarkId
}
class Version(id: EntityID<Uuid>) : Entity<Uuid>(id) {
@@ -71,4 +98,20 @@ class Entry(id: EntityID<Uuid>) : Entity<Uuid>(id) {
var project by Project referencedOn Entries.projectId
var version by Version referencedOn Entries.versionId
}
class EntryV2(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, EntryV2>(EntriesV2)
var benchmarkId by EntriesV2.benchmarkId
var versionId by EntriesV2.versionId
var timestamp by EntriesV2.timestamp
var warmups by EntriesV2.warmups
var measurements by EntriesV2.measurements
var unit by EntriesV2.unit
var input by EntriesV2.input
var hardware by EntriesV2.hardware
var benchmark by Benchmark referencedOn EntriesV2.benchmarkId
var version by Version referencedOn EntriesV2.versionId
}
@@ -2,16 +2,26 @@ package com.jaytux.phoebench.server.db
import com.jaytux.phoebench.common.TimeUnit
import org.jetbrains.exposed.v1.core.ReferenceOption
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.core.dao.id.UuidTable
import org.jetbrains.exposed.v1.datetime.timestamp
import kotlin.time.Clock
object Metas : IntIdTable() {
const val CURRENT_REVISION = 1
val revision = integer("revision").default(0)
}
object Users : UuidTable() {
val username = varchar("username", 255).uniqueIndex()
val password = varchar("password", 255)
val isAdmin = bool("is_admin").default(false)
val isOwner = bool("is_owner").default(false)
val projectLimit = integer("project_limit").default(1)
val benchmarkLimit = integer("benchmark_limit").default(1)
val projectsVersion = integer("project_version").default(0)
}
object Invites : UuidTable() {
@@ -25,6 +35,8 @@ object RefreshTokens : UuidTable() {
}
object Projects : UuidTable() {
const val CURRENT_VERSION = 1
val ownerId = reference("owner_id", Users, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val name = varchar("name", 255)
val isPublic = bool("is_public").default(false)
@@ -34,6 +46,15 @@ object Projects : UuidTable() {
}
}
object Benchmarks : UuidTable() {
val projectId = reference("project_id", Projects, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val name = varchar("name", 255)
init {
uniqueIndex(projectId, name)
}
}
object Versions : UuidTable() {
val label = varchar("label", 255)
val color = varchar("color", 7)
@@ -50,4 +71,15 @@ object Entries : UuidTable() {
val unit = enumeration<TimeUnit>("unit")
val input = varchar("input", 255)
val hardware = varchar("hardware", 255)
}
object EntriesV2 : UuidTable() {
val benchmarkId = reference("benchmark_id", Benchmarks, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val versionId = reference("version_id", 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)
}
@@ -1,22 +1,31 @@
package com.jaytux.phoebench.server.handlers
import com.jaytux.phoebench.common.BenchmarkRequest
import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.BenchmarkSummary
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.EmptyResponse
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.Migrate0To1Request
import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.NamedID
import com.jaytux.phoebench.common.PartialBenchmarkRequest
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.app
import com.jaytux.phoebench.server.db.Benchmark
import com.jaytux.phoebench.server.db.Benchmarks
import com.jaytux.phoebench.server.db.Entries
import com.jaytux.phoebench.server.db.Entry
import com.jaytux.phoebench.server.db.EntryV2
import com.jaytux.phoebench.server.db.Version
import com.jaytux.phoebench.server.db.Versions
import com.jaytux.phoebench.server.db.Project
@@ -27,7 +36,12 @@ import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.launch
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.notInList
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.uuid.Uuid
@@ -40,23 +54,44 @@ object ProjectHandler {
return when {
isEditableBy(user) -> this
isPublic && !forEditing -> this
else -> throw RouteError("Invalid project ID.", HttpStatusCode.NotFound)
else -> throw RouteError("Invalid ID.", HttpStatusCode.NotFound)
}
}
context(trns: Transaction)
fun Benchmark.isAccessible(user: User, forEditing: Boolean): Pair<Project, Benchmark> {
return project.isAccessible(user, forEditing) to this
}
fun Transaction.accessibleProject(user: User, id: Uuid, forEditing: Boolean): Project {
val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound)
return project.isAccessible(user, forEditing)
}
fun Transaction.accessibleBenchmark(user: User, id: Uuid, forEditing: Boolean): Pair<Project, Benchmark> {
val bench = Benchmark.findById(id) ?: throw RouteError("Invalid benchmark ID.", HttpStatusCode.NotFound)
return bench.isAccessible(user, forEditing)
}
fun Transaction.checkMigration(user: User) {
if(user.projectVersion < Projects.CURRENT_VERSION)
throw RouteError("You need to migrate your projects first.", HttpStatusCode.Conflict)
}
context(trns: Transaction)
private fun Project.toResponse(user: User) = ProjectResponse(
id.value, name, NamedID(owner.username, owner.id.value), isPublic, isEditableBy(user),
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) })
benchmarks.orderBy(Benchmarks.name to SortOrder.ASC).map { b -> BenchmarkResponse(
b.id.value, b.name, b.entries.map { e ->
EntryResponse(e.id.value, e.benchmarkId.value, e.versionId.value, e.timestamp, e.warmups, e.measurements, e.unit, e.input, e.hardware)
}
) },
versions.orderBy(Versions.label to SortOrder.ASC).map { VersionResponse(it.id.value, it.label, it.color, it.meta) }
)
fun home(user: User, req: EmptyRequest) = transaction {
val own = user.projects.orderBy(Projects.name to SortOrder.ASC).map {
val own = user.projects.orderBy(Projects.name to SortOrder.ASC).mapNotNull {
if(it.name.startsWith("<migrate")) return@mapNotNull null
HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value))
}
@@ -64,10 +99,23 @@ object ProjectHandler {
HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value))
}
success(HomeResponse(user.username, user.isAdmin, user.projectLimit, own, publics))
val migrations = mutableListOf<HomeResponse.NeededMigration>()
var version = user.projectVersion
while(version < Projects.CURRENT_VERSION) {
migrations += when(version) {
0 -> HomeResponse.NeededMigration.Migrate0To1(user.projects.map { p ->
HomeResponse.NeededMigration.Migrate0To1.OldProject(p.id.value, p.name, p.versions.map { VersionResponse(it.id.value, it.label, it.color, it.meta) })
})
else -> throw IllegalArgumentException("No migration starting from $version possible.")
}
++version
}
success(HomeResponse(user.username, user.isAdmin, user.projectLimit, own, publics, migrations))
}
suspend fun createProject(user: User, req: ProjectRequest) = transaction {
checkMigration(user)
if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit))
throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict)
@@ -88,11 +136,13 @@ object ProjectHandler {
}
fun getProject(user: User, req: Uuid) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req, false)
success(proj.toResponse(user))
}
fun updateProject(user: User, req: Pair<Uuid, PartialProjectRequest>) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req.first, true)
val changes = req.second
changes.name?.let { proj.name = it }
@@ -113,6 +163,7 @@ object ProjectHandler {
}
fun deleteProject(user: User, req: Uuid) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req, true)
proj.delete()
ServerScope.launch {
@@ -124,8 +175,57 @@ object ProjectHandler {
success(EmptyResponse())
}
fun createVersion(user: User, req: VersionRequest) = transaction {
fun createBenchmark(user: User, req: BenchmarkRequest) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req.projectId, true)
val bench = Benchmark.new {
name = req.name
projectId = proj.id
}
val res = BenchmarkResponse(bench.id.value, bench.name, bench.entries.map {
EntryResponse(it.id.value, it.benchmarkId.value, it.versionId.value, it.timestamp, it.warmups, it.measurements, it.unit, it.input, it.hardware)
})
ServerScope.launch {
val projectBus = Buses.projectBus(proj.id.value)
if(proj.isPublic) projectBus.sendAll(ProjectEvent.NewBenchmark(res))
else projectBus.send(user.id.value, ProjectEvent.NewBenchmark(res))
}
success(res)
}
fun updateBenchmark(user: User, req: Pair<Uuid, PartialBenchmarkRequest>) = transaction {
checkMigration(user)
val (proj, bench) = accessibleBenchmark(user, req.first, true)
val changes = req.second
changes.name?.let { bench.name = it }
ServerScope.launch {
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.BenchmarkChanged(
BenchmarkSummary(bench.id.value, bench.name)
))
}
success(EmptyResponse())
}
fun deleteBenchmark(user: User, req: Uuid) = transaction {
checkMigration(user)
val (proj, bench) = accessibleBenchmark(user, req, true)
bench.delete()
ServerScope.launch {
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.BenchmarkDeleted(req))
}
success(EmptyResponse())
}
fun createVersion(user: User, req: VersionRequest) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req.benchmarkId, true)
if(req.color.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest)
val ver = Version.new {
label = req.name
@@ -136,13 +236,14 @@ object ProjectHandler {
val res = VersionResponse(ver.id.value, ver.label, ver.color, ver.meta)
ServerScope.launch {
Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewVersion(res))
Buses.projectBus(req.benchmarkId).sendAll(ProjectEvent.NewVersion(res))
}
success(res)
}
fun updateVersion(user: User, req: Pair<Uuid, PartialVersionRequest>) = transaction {
checkMigration(user)
val ver = Version.findById(req.first) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
ver.project.isAccessible(user, true)
val changes = req.second
@@ -163,6 +264,7 @@ object ProjectHandler {
}
fun deleteVersion(user: User, req: Uuid) = transaction {
checkMigration(user)
val ver = Version.findById(req) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
ver.project.isAccessible(user, true)
ver.delete()
@@ -175,14 +277,15 @@ object ProjectHandler {
}
fun createEntry(user: User, req: EntryRequest) = transaction {
val proj = accessibleProject(user, req.projectId, true)
val entry = Entry.new {
checkMigration(user)
val (proj, bench) = accessibleBenchmark(user, req.benchmarkId, true)
val entry = EntryV2.new {
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
benchmarkId = bench.id
measurements = req.measurements
timestamp = req.timestamp
warmups = req.warmups
@@ -191,7 +294,8 @@ object ProjectHandler {
hardware = req.hardware
}
val response = EntryResponse(entry.id.value, entry.version.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit, entry.input, entry.hardware)
val response = EntryResponse(entry.id.value, entry.benchmarkId.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))
}
@@ -199,12 +303,82 @@ object ProjectHandler {
}
fun deleteEntry(user: User, req: Uuid) = transaction {
val entry = Entry.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
entry.project.isAccessible(user, true)
checkMigration(user)
val entry = EntryV2.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
val (proj, bench) = entry.benchmark.isAccessible(user, true)
entry.delete()
ServerScope.launch {
Buses.projectBus(entry.project.id.value).sendAll(ProjectEvent.EntryDeleted(req))
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.EntryDeleted(req, bench.id.value))
}
success(EmptyResponse())
}
fun migrate01(user: User, req: Migrate0To1Request): Pair<HttpStatusCode, HomeResponse> {
val givenProjectIds = req.conversions.flatMap { it.oldProjectsAsBenchmarks }.toSet()
val totalProjectCount = req.conversions.sumOf { it.oldProjectsAsBenchmarks.size }
if(totalProjectCount != givenProjectIds.size)
throw RouteError("Each (legacy) project should only be bound to a single (migrated) project", HttpStatusCode.BadRequest)
val userProjects = transaction { user.projects.toList() }.map { it.id.value }.toSet()
if(givenProjectIds.any { it !in userProjects })
throw RouteError("Only a user's own projects should be migrated.", HttpStatusCode.Conflict)
transaction {
user.projects.forEach { it.name = "${it.name}#legacy" }
}
transaction {
req.conversions.forEach { (newName, isPublic, olds) ->
val proj = Project.new {
ownerId = user.id
name = newName
this.isPublic = isPublic
}
val versionMap = mutableMapOf<Uuid, EntityID<Uuid>>()
val duplicateMap = mutableMapOf<String, EntityID<Uuid>>()
Version.find { Versions.projectId inList olds }.forEach { oldVersion ->
val targetId = duplicateMap.getOrPut(oldVersion.label) {
oldVersion.projectId = proj.id
oldVersion.id
}
versionMap[oldVersion.id.value] = targetId
}
olds.forEach { oldId ->
val oldProj = accessibleProject(user, oldId, true)
val benchmark = Benchmark.new {
projectId = proj.id
name = oldProj.name.removeSuffix("#legacy")
}
Entry.find { Entries.projectId eq oldId }.forEach {
EntryV2.new {
benchmarkId = benchmark.id
versionId = versionMap[it.versionId.value]!!
timestamp = it.timestamp
warmups = it.warmups
measurements = it.measurements
unit = it.unit
input = it.input
hardware = it.hardware
}
it.delete()
}
}
Versions.deleteWhere {
(Versions.projectId inList olds) and (Versions.id notInList versionMap.values.toList())
}
Projects.deleteWhere {
Projects.id inList olds
}
}
user.projectVersion = 1
}
return home(user, EmptyRequest())
}
}
@@ -0,0 +1 @@
org.slf4j.simpleLogger.log.io.ktor.server.plugins.cors=TRACE