4 Commits
32 changed files with 1223 additions and 139 deletions
+3
View File
@@ -45,7 +45,10 @@ bin/
.DS_Store .DS_Store
*.db *.db
*.bak*
.env .env
.env.*
*.sql
.idea/ .idea/
artifacts/ artifacts/
+2
View File
@@ -2,6 +2,8 @@
set -e set -e
export PHOEBENCH_BUILD_RELEASE='true'
./gradlew :clients:cli:shadowJar ./gradlew :clients:cli:shadowJar
./gradlew :clients:compose:createDistributable ./gradlew :clients:compose:createDistributable
./gradlew :clients:compose:wasmJsBrowserProductionWebpack ./gradlew :clients:compose:wasmJsBrowserProductionWebpack
+3 -1
View File
@@ -1,3 +1,5 @@
import PhoebenchVersion
plugins { plugins {
alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.composeCompiler) apply false
@@ -12,4 +14,4 @@ repositories {
mavenCentral() mavenCentral()
} }
version = "1.0.0" 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"
}
+4 -1
View File
@@ -8,7 +8,10 @@ plugins {
} }
group = "com.jaytux.phoebench" group = "com.jaytux.phoebench"
version = "1.0.1" version = rootProject.version as PhoebenchVersion
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 versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val generateVersion = tasks.register<Task>("serverVersion") { val generateVersion = tasks.register<Task>("serverVersion") {
@@ -101,10 +101,11 @@ object CLI {
override fun nestedCommands(): Array<CliktCommand> = fromReflection() override fun nestedCommands(): Array<CliktCommand> = fromReflection()
sealed interface IProjectIdentification sealed interface IProjectIdentification
sealed interface ILabelIdentification sealed interface IVersionIdentification
sealed interface IBenchmarkIdentification
data class ProjectName(val user: String, val project: String) : IProjectIdentification data class ProjectName(val user: String, val project: String) : IProjectIdentification
data class VersionName(val name: String): ILabelIdentification data class Name(val name: String): IVersionIdentification, IBenchmarkIdentification
data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification data class ID(val id: Uuid) : IProjectIdentification, IVersionIdentification, IBenchmarkIdentification
sealed interface IData<T> { sealed interface IData<T> {
fun toList(): List<T> fun toList(): List<T>
@@ -184,12 +185,24 @@ object CLI {
override fun run() = ProjectHandlers.newVersion(name, color, parent.finder, meta) 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") @Suppress("unused")
class AddData : CliktCommand(name = "add-data") { class AddData : CliktCommand(name = "add-data") {
val parent by requireObject<Project>() val parent by requireObject<Project>()
val 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-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() ).single()
val warmup by mutuallyExclusiveOptions<IData<Float>>( val warmup by mutuallyExclusiveOptions<IData<Float>>(
option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) }, option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
@@ -210,7 +223,7 @@ object CLI {
val input by option("--input", help = "Set the input used to benchmark") 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") 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)
} }
} }
@@ -1,6 +1,7 @@
package com.jaytux.phoebench.clients.cli package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.clients.cli.CLI.Commands.Project.ProjectName import com.jaytux.phoebench.clients.cli.CLI.Commands.Project.ProjectName
import com.jaytux.phoebench.common.BenchmarkRequest
import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.EmptyRequest import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.EntryRequest import com.jaytux.phoebench.common.EntryRequest
@@ -29,6 +30,7 @@ object ProjectHandlers {
Client.callRoute(Routes.home, EmptyRequest()) Client.callRoute(Routes.home, EmptyRequest())
}.fold({ err -> }.fold({ err ->
System.err.println("Could not get projects list: ${err.msg}") System.err.println("Could not get projects list: ${err.msg}")
exitProcess(-1)
}) { }) {
System.err.println("Logged in as ${it.username}") System.err.println("Logged in as ${it.username}")
(it.ownProjects + it.publicProjects).toSet().sortedBy { p -> p.name }.forEach { project -> (it.ownProjects + it.publicProjects).toSet().sortedBy { p -> p.name }.forEach { project ->
@@ -65,18 +67,22 @@ object ProjectHandlers {
} }
}.fold({ }.fold({
System.err.println("Failed to get project details: ${it.msg}") System.err.println("Failed to get project details: ${it.msg}")
exitProcess(-1)
}) { }) {
println("Project ${it.owner.name}/${it.name} [${it.id}]:") println("Project ${it.owner.name}/${it.name} [${it.id}]:")
println("${it.usedLabels.size} versions:") println("${it.versions.size} versions:")
it.usedLabels.forEach { v -> it.versions.forEach { v ->
println(" - [${v.id}] VERSION ${v.name} (with color ${v.color}); metadata: ${v.meta}") 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:") println("\n${it.benchmarks.size} benchmarks:")
it.entries.forEach { e -> it.benchmarks.forEach { b ->
println(" - [${e.id}] ENTRY versioned ${map[e.labelId]?.name ?: "<invalid version>"} at ${e.timestamp.fmt()} " + println(" - [${b.id}] ${b.name} with ${b.entries.size} entries:")
"(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})") 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)) Client.callRoute(Routes.Project.new, ProjectRequest(useName, isPublic))
}.fold({ }.fold({
System.err.println("Failed to create project '$useName': ${it.msg}") System.err.println("Failed to create project '$useName': ${it.msg}")
exitProcess(-1)
}) { }) {
println("Project ${it.owner.name}/${it.name} [${it.id}] created.") println("Project ${it.owner.name}/${it.name} [${it.id}] created.")
} }
@@ -111,8 +118,25 @@ object ProjectHandlers {
} }
}.fold({ }.fold({
System.err.println("Could not create version: ${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( fun newData(
project: CLI.Commands.Project.IProjectIdentification?, 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>?, warmup: CLI.Commands.Project.IData<Float>?,
measurement: CLI.Commands.Project.IData<Float>?, measurement: CLI.Commands.Project.IData<Float>?,
unit: TimeUnit?, input: String?, hardware: String? unit: TimeUnit?, input: String?, hardware: String?
) { ) {
val projectId = ensureProjectIdentification(project) val projectId = ensureProjectIdentification(project)
val versionId = label.maybePrompt("version name") { 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 warmupData = warmup.ensure("warmup").toList()
val measureData = measurement.ensure("measurements").toList() val measureData = measurement.ensure("measurements").toList()
@@ -160,19 +188,30 @@ object ProjectHandlers {
tryAuthenticated { tryAuthenticated {
projectId.toId().bind { pId -> projectId.toId().bind { pId ->
when(versionId) { Client.callRoute(Routes.Project.get, pId).bind { p ->
is CLI.Commands.Project.ID -> versionId.id.value() when (versionId) {
is CLI.Commands.Project.VersionName -> Client.callRoute(Routes.Project.get, pId).bind { is CLI.Commands.Project.ID -> versionId.id.value()
it.usedLabels.firstOrNull { l -> l.name == versionId.name }?.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() ?: 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( Client.callRoute(Routes.Entry.new, EntryRequest(
vId, Clock.System.now(), pId, vId, Clock.System.now(), bId,
warmupData, measureData, timeUnit, useInput, useHardware warmupData, measureData, timeUnit, useInput, useHardware
)) ))
} }
}.fold({
System.err.println("Failed to add data: ${it.msg}")
exitProcess(-1)
}) {
println("Data added successfully.")
} }
} }
} }
+26 -2
View File
@@ -13,7 +13,10 @@ plugins {
alias(libs.plugins.buildconfig) alias(libs.plugins.buildconfig)
} }
version = "1.0.1" 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 versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val generateVersion = tasks.register<Task>("clientVersion") { val generateVersion = tasks.register<Task>("clientVersion") {
@@ -128,7 +131,7 @@ compose.desktop {
nativeDistributions { nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.jaytux.phoebench.clients" packageName = "com.jaytux.phoebench.clients"
packageVersion = version.toString() packageVersion = (version as PhoebenchVersion).safeString()
jvmArgs += listOf("--enable-native-access=ALL-UNNAMED") jvmArgs += listOf("--enable-native-access=ALL-UNNAMED")
includeAllModules = true includeAllModules = true
@@ -154,4 +157,25 @@ fun envValue(key: String): String? {
} }
.firstOrNull { it.first == key }?.second .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,9 +1,13 @@
package com.jaytux.phoebench.clients package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig import io.ktor.client.HttpClientConfig
import kotlinx.serialization.KSerializer
import kotlinx.serialization.serializer
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
interface IStore { interface IStore {
@@ -26,4 +30,12 @@ expect suspend fun String.toClipEntry(): ClipEntry
expect fun format(f: Float): String expect fun format(f: Float): String
@Composable @Composable
expect fun ServerSelect(server: String, onUpdate: (String) -> Unit) 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.format.char
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.pow
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.random.Random import kotlin.random.Random
import kotlin.random.nextInt import kotlin.random.nextInt
@@ -85,8 +86,21 @@ inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T
return toMutableList().apply { add(index, elem) } return toMutableList().apply { add(index, elem) }
} }
fun List<Float>.geomean(): Float { inline fun <T, X : Comparable<X>> List<T>.insortIdx(elem: T, crossinline sortBy: (T) -> X): Pair<List<T>, Int?> {
return (size.toFloat() / sumOf { 1.0 / it.toDouble() }).toFloat() 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) infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
@@ -122,4 +136,12 @@ fun <T1, T2, T3, T4> List<Tuple4<T1, T2, T3, T4>>.unzip(): Tuple4<List<T1>, List
} }
return l1 to l2 app l3 app l4 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.HomeEvent
import com.jaytux.phoebench.common.HomeResponse import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.InviteListResponse import com.jaytux.phoebench.common.InviteListResponse
import com.jaytux.phoebench.common.Migrate0To1Request
import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.UserListResponse import com.jaytux.phoebench.common.UserListResponse
import com.jaytux.phoebench.common.value import com.jaytux.phoebench.common.value
@@ -40,11 +41,14 @@ class HomeVM(
private val _users = mutableStateOf(listOf<UserListResponse.UserData>()) private val _users = mutableStateOf(listOf<UserListResponse.UserData>())
private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>()) private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>())
private val _stepsNeeded = mutableStateOf(listOf<HomeResponse.NeededMigration>())
val username = _username.immutable() val username = _username.immutable()
val isAdmin = _isAdmin.immutable() val isAdmin = _isAdmin.immutable()
val projectLimit = _projectLimit.immutable() val projectLimit = _projectLimit.immutable()
val ownProjects = _ownProjects.immutable() val ownProjects = _ownProjects.immutable()
val publicProjects = _publicProjects.immutable() val publicProjects = _publicProjects.immutable()
val stepsNeeded = _stepsNeeded.immutable()
val invites = _invites.immutable() val invites = _invites.immutable()
val users = _users.immutable() val users = _users.immutable()
@@ -81,17 +85,22 @@ class HomeVM(
withScope { withScope {
resetAdmin() resetAdmin()
_repo.getHome().snackOr { _repo.getHome().snackOr {
println("Received home response $it") refreshFrom(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()
} }
} }
} }
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() { fun refreshAdmin() {
if(_isAdmin.value) { if(_isAdmin.value) {
_adminJob = withScope { _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.InviteListResponse
import com.jaytux.phoebench.common.InviteRequest import com.jaytux.phoebench.common.InviteRequest
import com.jaytux.phoebench.common.LogoutRequest import com.jaytux.phoebench.common.LogoutRequest
import com.jaytux.phoebench.common.Migrate0To1Request
import com.jaytux.phoebench.common.ProjectRequest import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.UserListResponse import com.jaytux.phoebench.common.UserListResponse
@@ -31,6 +32,8 @@ interface IHomeRepo {
suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit>
suspend fun migrate0To1(conversions: List<Migrate0To1Request.MigrateProject>): Either<ErrorResponse, HomeResponse>
companion object { companion object {
class Default(private val _client: Client) : IHomeRepo { class Default(private val _client: Client) : IHomeRepo {
override suspend fun getHome(): Either<ErrorResponse, HomeResponse> = override suspend fun getHome(): Either<ErrorResponse, HomeResponse> =
@@ -62,6 +65,9 @@ interface IHomeRepo {
override suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> = override suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.new, ProjectRequest(name, isPublic)).ignoreValue() _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) 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.Client
import com.jaytux.phoebench.clients.hexString import com.jaytux.phoebench.clients.hexString
import com.jaytux.phoebench.clients.ignoreValue 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.Either
import com.jaytux.phoebench.common.EntryRequest import com.jaytux.phoebench.common.EntryRequest
import com.jaytux.phoebench.common.EntryResponse import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.ErrorResponse import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.PartialBenchmarkRequest
import com.jaytux.phoebench.common.VersionRequest import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.VersionResponse import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.PartialProjectRequest 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 update(name: String? = null, isPublic: Boolean? = null): Either<ErrorResponse, Unit>
suspend fun delete(): 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 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 updateVersion(id: Uuid, name: String? = null, color: Color? = null, meta: String? = null): Either<ErrorResponse, Unit>
suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit> suspend fun 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> unit: TimeUnit, input: String, hardware: String): Either<ErrorResponse, EntryResponse>
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
@@ -42,6 +49,15 @@ interface IProjectRepo {
override suspend fun delete(): Either<ErrorResponse, Unit> = override suspend fun delete(): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.delete, _projectId).ignoreValue() _client.callRoute(Routes.Project.delete, _projectId).ignoreValue()
override suspend fun 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> = override suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse> =
_client.callRoute(Routes.Version.new, VersionRequest(name, color.hexString(), meta, _projectId)) _client.callRoute(Routes.Version.new, VersionRequest(name, color.hexString(), meta, _projectId))
@@ -51,10 +67,10 @@ interface IProjectRepo {
override suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit> = override suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Version.delete, id).ignoreValue() _client.callRoute(Routes.Version.delete, id).ignoreValue()
override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, override suspend fun newEntry(benchmark: Uuid, label: Uuid, timestamp: Instant, warmups: List<Float>,
measurements: List<Float>, unit: TimeUnit, input: String, hardware: String measurements: List<Float>, unit: TimeUnit, input: String, hardware: String
): Either<ErrorResponse, EntryResponse> = ): Either<ErrorResponse, EntryResponse> =
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit, 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> = override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Entry.delete, id).ignoreValue() _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.hexString
import com.jaytux.phoebench.clients.immutable import com.jaytux.phoebench.clients.immutable
import com.jaytux.phoebench.clients.insort import com.jaytux.phoebench.clients.insort
import com.jaytux.phoebench.clients.insortIdx
import com.jaytux.phoebench.clients.replace
import com.jaytux.phoebench.clients.withScope import com.jaytux.phoebench.clients.withScope
import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.EntryResponse import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.VersionResponse import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.ProjectEvent 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>, 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) { val measurements: List<Float>, val nativeUnit: TimeUnit, val input: String, val hardware: String) {
companion object { companion object {
fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>) = Entry( inline fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>, addInput: (String) -> Unit, addHardware: (String) -> Unit) = Entry(
it.id, map[it.labelId] ?: Version.invalid, it.timestamp, it.id, map[it.versionId] ?: Version.invalid, it.timestamp,
it.warmups, it.measurements, it.unit, it.input, it.hardware 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 _public = mutableStateOf(false)
private val _editable = mutableStateOf(false) private val _editable = mutableStateOf(false)
private val _labels = mutableStateOf(mapOf<Uuid, Version>()) 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 _inputs = mutableStateOf(setOf<String>())
private val _hardware = mutableStateOf(setOf<String>()) private val _hardware = mutableStateOf(setOf<String>())
@@ -78,7 +93,8 @@ class ProjectVM(
val public = _public.immutable() val public = _public.immutable()
val editable = _editable.immutable() val editable = _editable.immutable()
val versions = _labels.immutable() val versions = _labels.immutable()
val entries = _entries.immutable() val benchmarks = _benchmarks.immutable()
val currentBenchmark = _currentBenchmark.immutable()
val inputs = _inputs.immutable() val inputs = _inputs.immutable()
val hardware = _hardware.immutable() val hardware = _hardware.immutable()
@@ -102,14 +118,10 @@ class ProjectVM(
_owner.value = it.owner.name _owner.value = it.owner.name
_public.value = it.isPublic _public.value = it.isPublic
_editable.value = it.isEditable _editable.value = it.isEditable
_labels.value = it.usedLabels.associate { l -> l.id to Version.fromResponse(l) } _labels.value = it.versions.associate { l -> l.id to Version.fromResponse(l) }
val localInputs = mutableSetOf<String>() val localInputs = mutableSetOf<String>()
val localHardware = mutableSetOf<String>() val localHardware = mutableSetOf<String>()
_entries.value = it.entries.map { e -> _benchmarks.value = it.benchmarks.map { b -> Benchmark.fromResponse(b, _labels.value, localInputs::add, localHardware::add) }
localInputs += e.input
localHardware += e.hardware
Entry.fromResponse(e, _labels.value)
}
_inputs.value = localInputs _inputs.value = localInputs
_hardware.value = localHardware _hardware.value = localHardware
} }
@@ -119,13 +131,12 @@ class ProjectVM(
private fun handleProjectEvent(event: ProjectEvent) { private fun handleProjectEvent(event: ProjectEvent) {
when(event) { when(event) {
ProjectEvent.Deleted -> back() ProjectEvent.Deleted -> back()
is ProjectEvent.EntryDeleted -> _entries.value = _entries.value.filter { it.id != event.id } is ProjectEvent.EntryDeleted -> _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.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.VersionDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id }
is ProjectEvent.NewEntry -> { is ProjectEvent.NewEntry -> {
_entries.value = _entries.value.insort(Entry.fromResponse(event.entry, _labels.value), Entry::timeStamp) _benchmarks.value = _benchmarks.value.replace({ it.id == event.entry.benchmarkId }) { it.add(Entry.fromResponse(event.entry, _labels.value,
_inputs.value += event.entry.input { input -> _inputs.value += input }, { hw -> _hardware.value += hw })) }
_hardware.value += event.entry.hardware
} }
is ProjectEvent.NewVersion -> _labels.value += (event.label.id to Version.fromResponse(event.label)) is ProjectEvent.NewVersion -> _labels.value += (event.label.id to Version.fromResponse(event.label))
is ProjectEvent.Updated -> { is ProjectEvent.Updated -> {
@@ -133,6 +144,23 @@ class ProjectVM(
_owner.value = event.changes.owner.name _owner.value = event.changes.owner.name
_public.value = event.changes.isPublic _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
}
} }
} }
@@ -183,24 +211,69 @@ class ProjectVM(
} }
} }
fun mkEntry(label: Version, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit, input: String, hardware: String) { fun mkBenchmark(name: String) {
withScope { withScope {
_repo.newEntry(label.id, Clock.System.now(), warmups, measurements, unit, input, hardware).snackOr { _repo.newBenchmark(name).snackOr { bench ->
if(_job == null) { if(_job == null) {
_entries.value += Entry.fromResponse(it, _labels.value) val (updated, idx) = _benchmarks.value.insortIdx(
_inputs.value += input Benchmark.fromResponse(bench, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw })
_hardware.value += hardware ) { 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 { withScope {
_repo.deleteEntry(id).snackOr { val old = _benchmarks.value.find { it.id == id } ?: return@withScope
if(_job == null) _repo.updateBenchmark(id, name).snackOr {
_entries.value = _entries.value.filter { it.id != id } 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)
}
} }
@@ -320,7 +320,19 @@ fun ConfirmLogoutDialog(onCancel: () -> Unit, onLogout: (everywhere: Boolean) ->
@Composable @Composable
fun HomeView(onSelectProject: (Uuid) -> Unit) { fun HomeView(onSelectProject: (Uuid) -> Unit) {
val vm = viewModel { HomeVM() } 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 auth = AuthProvider.get()
val username by vm.username val username by vm.username
val isAdmin by vm.isAdmin 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())
}
}
}
}
@@ -50,13 +50,13 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
val owner by vm.owner val owner by vm.owner
val public by vm.public val public by vm.public
val editable by vm.editable val editable by vm.editable
val labels by vm.versions val versions by vm.versions
val benchmarks by vm.benchmarks
val currentBenchmark by vm.currentBenchmark
var editing by remember { mutableStateOf(false) } var editing by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(false) } var deleting by remember { mutableStateOf(false) }
var addOpen by remember { mutableStateOf(false) } var addingBenchmark by remember { mutableStateOf(false) }
var addingLabel by remember { mutableStateOf(false) }
var addingData by remember { mutableStateOf(false) }
Column(Modifier.padding(all = 15.dp)) { Column(Modifier.padding(all = 15.dp)) {
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
@@ -73,9 +73,85 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
} }
owner?.let { Text("${if(public) "Public" else "Private"} project by user $it") } owner?.let { Text("${if(public) "Public" else "Private"} project by user $it") }
Spacer(Modifier.height(15.dp)) Spacer(Modifier.height(15.dp))
Surface(Modifier.fillMaxSize().padding(8.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) { 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(addingBenchmark) {
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
Box(Modifier.clickable { addingBenchmark = false }.fillMaxSize())
}
}
Box(Modifier.align(Alignment.BottomEnd).padding(25.dp)) {
IconButton({ addingBenchmark = true }, Modifier.scale(1.5f), shape = MaterialTheme.shapes.medium) {
Icon(Lucide.Plus, "Add benchmark")
}
}
}
}
}
}
if(editing) ProjectEditDialog(name ?: "Unnamed Project", public, { editing = false }) { name, public ->
vm.update(name, public)
}
if(deleting) ConfirmDeleteProjectDialog(name ?: "Unnamed Project", { deleting = false }) {
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)) { Box(Modifier.fillMaxSize().padding(15.dp)) {
ProjectPlotArea(vm) ProjectPlotArea(vm, currentBenchmark)
if(editable) { if(editable) {
if(addOpen) { if(addOpen) {
@@ -88,9 +164,22 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
if (addOpen) { if (addOpen) {
var lblWidth by mutableStateOf(1000.dp) var lblWidth by mutableStateOf(1000.dp)
var dataWidth by mutableStateOf(1000.dp) var dataWidth by mutableStateOf(1000.dp)
var benchWidth by mutableStateOf(1000.dp)
val density = LocalDensity.current val density = LocalDensity.current
Column(Modifier.width(maxOf(lblWidth, dataWidth)), horizontalAlignment = Alignment.End) { 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 { Surface(Modifier.onGloballyPositioned {
lblWidth = with(density) { it.size.width.toDp() } lblWidth = with(density) { it.size.width.toDp() }
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) { }, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
@@ -126,14 +215,6 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
} }
} }
if(editing) ProjectEditDialog(name ?: "Unnamed Project", public, { editing = false }) { name, public ->
vm.update(name, public)
}
if(deleting) ConfirmDeleteProjectDialog(name ?: "Unnamed Project", { deleting = false }) {
vm.delete()
}
if(addingLabel) AddVersionDialog({ addingLabel = false; addOpen = false }) { name, color, meta -> if(addingLabel) AddVersionDialog({ addingLabel = false; addOpen = false }) { name, color, meta ->
vm.mkLabel(name, color, meta) vm.mkLabel(name, color, meta)
addOpen = false addOpen = false
@@ -142,13 +223,15 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
val usedInputs by vm.inputs val usedInputs by vm.inputs
val usedHardware by vm.hardware val usedHardware by vm.hardware
if(addingData) AddEntryDialog(labels, usedInputs, usedHardware, if(addingData) AddEntryDialog(versions, usedInputs, usedHardware,
onCancel = { addingData = false; addOpen = false }, onCancel = { addingData = false; addOpen = false },
onAddLbl = { name, lbl, meta -> vm.mkLabel(name, lbl, meta) } onAddLbl = { name, lbl, meta -> vm.mkLabel(name, lbl, meta) }
) { label, warmups, measurements, unit, input, hardware -> ) { label, warmups, measurements, unit, input, hardware ->
vm.mkEntry(label, warmups, measurements, unit, input, hardware) vm.mkEntry(label, warmups, measurements, unit, input, hardware)
addOpen = false addOpen = false
} }
if(editing) EditBenchmarkDialog(currentBenchmark, { editing = false }) { vm.updateBenchmark(currentBenchmark.id, it) }
} }
@Composable @Composable
@@ -159,7 +242,7 @@ fun ProjectEditDialog(name: String, public: Boolean, onCancel: () -> Unit, onSav
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) { Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) { 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") }) OutlinedTextField(updName, { updName = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Row(Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically) {
@@ -193,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 @Composable
fun AddVersionDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color, meta: String) -> Unit) { fun AddVersionDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color, meta: String) -> Unit) {
Dialog(onDismissRequest = onCancel) { Dialog(onDismissRequest = onCancel) {
@@ -212,7 +333,7 @@ fun AddVersionDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color, m
CancelConfirmXRow(onCancel, { CancelConfirmXRow(onCancel, {
onAdd(name.trim(), color, meta.trim()) onAdd(name.trim(), color, meta.trim())
}, confirmText = "Create", canConfirm = name.trim().isNotBlank() && meta.trim().isNotBlank()) }, confirmText = "Create", canConfirm = name.trim().isNotBlank())
} }
} }
} }
@@ -303,7 +424,7 @@ fun AddEntryDialog(
DropdownMenuItem({ DropdownMenuItem({
Text( Text(
"Create new label...", "Create new version...",
color = LocalContentColor.current.copy(alpha = 0.75f), color = LocalContentColor.current.copy(alpha = 0.75f),
fontStyle = FontStyle.Italic fontStyle = FontStyle.Italic
) )
@@ -380,38 +501,70 @@ fun AddEntryDialog(
} }
@Composable @Composable
fun ProjectPlotArea(vm: ProjectVM){ fun EditVersionDialog(current: ProjectVM.Version, onCancel: () -> Unit, onUpdate: (id: Uuid, name: String?, color: Color?, meta: String?) -> Unit) {
val entries by vm.entries 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 versions by vm.versions
val editable by vm.editable val editable by vm.editable
val allInputs by vm.inputs val allInputs by vm.inputs
val allHardware by vm.hardware 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 filteredEntries by remember { mutableStateOf(entries) }
var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) } var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) }
var timeMax by remember { mutableStateOf(Instant.DISTANT_FUTURE) } var timeMax by remember { mutableStateOf(Instant.DISTANT_FUTURE) }
var timeFilter by remember { mutableStateOf(0f..1f) } var timeFilter by remember { mutableStateOf(0f..1f) }
var timeFilterString by remember { mutableStateOf("") } var timeFilterString by remember { mutableStateOf("") }
val versionFilter = remember(versions) { mutableStateSetFrom(versions.values) } val versionFilter = remember(versions) { mutableStateSetFrom(versions.values) }
val labelVersionFilter by versionFilter.revision val inputFilter = remember(allInputs) { mutableStateSetFrom(allInputs) }
val enabledInputs = remember { mutableStateSetOf<String>() }
val enabledHardware = remember { mutableStateSetOf<String>() }
var filterInput by remember { mutableStateOf<String?>(null) }
var filterHardware by remember { mutableStateOf<String?>(null) } var filterHardware by remember { mutableStateOf<String?>(null) }
val versionFilterRev by versionFilter.revision
val inputFilterRev by inputFilter.revision
LaunchedEffect(entries, allInputs, allHardware, timeFilter, timeMin, timeMax, labelVersionFilter, filterInput, filterHardware) { 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) val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
enabledInputs.clear()
enabledHardware.clear() enabledHardware.clear()
enabledInputs.clear()
filteredEntries = entries.filter { entry -> filteredEntries = entries.filter { entry ->
if(entry.label !in versionFilter) return@filter false if(entry.label !in versionFilter) return@filter false
if(!(entry.timeStamp inRange timeRange)) return@filter false if(!(entry.timeStamp inRange timeRange)) return@filter false
if(filterInput != null && entry.input != filterInput) return@filter false if(entry.input !in inputFilter) return@filter false
if(filterHardware != null && entry.hardware != filterHardware) return@filter false if(filterHardware != null && entry.hardware != filterHardware) return@filter false
enabledInputs.add(entry.input)
enabledHardware.add(entry.hardware) enabledHardware.add(entry.hardware)
enabledInputs.add(entry.input)
true true
} }
} }
@@ -446,6 +599,7 @@ fun ProjectPlotArea(vm: ProjectVM){
var deletingVersion by remember { mutableStateOf<ProjectVM.Version?>(null) } 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 baseline by remember { mutableStateOf<ProjectVM.Version?>(null) }
var baselineComparison by remember { mutableStateOf<List<Tuple4<String, Float, Color, Float>>?>(null) } var baselineComparison by remember { mutableStateOf<List<Tuple4<String, Float, Color, Float>>?>(null) }
var baselineMax by remember { mutableStateOf(2.0f) } var baselineMax by remember { mutableStateOf(2.0f) }
@@ -462,21 +616,33 @@ fun ProjectPlotArea(vm: ProjectVM){
LaunchedEffect(filteredEntries, baseline, currentUnit) { LaunchedEffect(filteredEntries, baseline, currentUnit) {
var max = 0.0f var max = 0.0f
baselineComparison = baseline?.let { bl -> baselineComparison = baseline?.let { bl ->
val perVersion = mutableMapOf<ProjectVM.Version, Pair<Float, Float>>() 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 -> filteredEntries.forEach { entry ->
val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum() val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum()
perVersion[entry.label] = perVersion[entry.label]?.let { perVersion.getOrPut(entry.label) { mutableMapOf() }
(previous, prCount) -> (previous + corrected) to (prCount + entry.measurements.size) .getOrPut(entry.input) { MeanAccumulator() }
} ?: (corrected to entry.measurements.size.toFloat()) .add(corrected)
} }
val means = perVersion.mapValues { (_, v) -> v.first / v.second } val subMeans = perVersion.mapValues { (_, v) -> v.mapValues { (_, acc) -> acc.mean() } }
val baselineData = means[bl] ?: return@let null val baseline = subMeans[bl] ?: return@let null
means.mapNotNull { (ver, data) ->
val speedup = baselineData / data val versionSpeedups = subMeans.mapValues { (_, v) ->
max = maxOf(max, speedup) v.mapNotNull { (input, mean) ->
ver.name to speedup app ver.uiColor app data 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 baselineMax = max
} }
@@ -614,6 +780,12 @@ fun ProjectPlotArea(vm: ProjectVM){
} }
} }
} }
if(editable) {
IconButton({ editingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) {
Icon(Lucide.Pencil, "Edit version")
}
}
IconButton({ baseline = if(baseline == lbl) null else lbl }, IconButton({ baseline = if(baseline == lbl) null else lbl },
Modifier.align(Alignment.CenterVertically), enabled = lbl in versionFilter) { Modifier.align(Alignment.CenterVertically), enabled = lbl in versionFilter) {
if(baseline == lbl) { if(baseline == lbl) {
@@ -644,14 +816,39 @@ fun ProjectPlotArea(vm: ProjectVM){
item { Spacer(Modifier.height(10.dp)) } item { Spacer(Modifier.height(10.dp)) }
items((allInputs intersect enabledInputs.toSet()).toList()) { items((visibleInputs intersect inputFilter.toSet()).sorted()) {
Row(Modifier.fillMaxWidth().clickable { filterInput = if(filterInput == null) it else null }) { Row(Modifier.fillMaxWidth().clickable {
Text(it, Modifier.padding(start = 5.dp)) 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((allInputs - enabledInputs.toSet()).toList()) { items((visibleInputs - inputFilter.toSet()).sorted()) {
Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f)) 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 { Spacer(Modifier.height(10.dp)) }
@@ -726,6 +923,13 @@ fun ProjectPlotArea(vm: ProjectVM){
vm.deleteVersion(rmV.id) vm.deleteVersion(rmV.id)
} }
} }
editingVersion?.let { edV ->
EditVersionDialog(edV, { editingVersion = null }) { id, name, color, meta ->
vm.updateLabel(id, name, color, meta)
editingVersion = null
}
}
} }
@Composable @Composable
@@ -2,6 +2,11 @@ package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi 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 androidx.compose.ui.platform.ClipEntry
import com.github.javakeyring.Keyring import com.github.javakeyring.Keyring
import com.jaytux.phoebench.clients.ui.DefaultServerSelect import com.jaytux.phoebench.clients.ui.DefaultServerSelect
@@ -10,12 +15,13 @@ import io.ktor.client.engine.okhttp.*
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
object JVMStore : IStore { object JVMStore : IStore {
private val json = Json private val json = Json
const val SERVICE = "com.jaytux.phoebench" const val SERVICE = PersistenceConstants.SERVICE
private class StoredProperty<T>( private class StoredProperty<T>(
val key: String, val key: String,
@@ -62,4 +68,17 @@ actual suspend fun String.toClipEntry(): ClipEntry =
actual fun format(f: Float): String = String.format("%.03f", f) actual fun format(f: Float): String = String.format("%.03f", f)
@Composable @Composable
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate) 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,6 +1,10 @@
package com.jaytux.phoebench.clients package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable 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 androidx.compose.ui.platform.ClipEntry
import com.jaytux.phoebench.clients.ui.constantServerSelect import com.jaytux.phoebench.clients.ui.constantServerSelect
import io.ktor.client.* import io.ktor.client.*
@@ -10,8 +14,10 @@ import kotlinx.coroutines.await
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import org.w3c.dom.DataTransfer
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
object WasmJsStore : IStore { object WasmJsStore : IStore {
@@ -74,4 +80,19 @@ lateinit var config: Config
@Composable @Composable
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) { actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) {
constantServerSelect(config.server)(server, onUpdate) 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 versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val requestsDirectory = layout.projectDirectory.dir("src/commonMain/kotlin/com/jaytux/phoebench/common/") val requestsDirectory = layout.projectDirectory.dir("src/commonMain/kotlin/com/jaytux/phoebench/common/")
version = rootProject.version as PhoebenchVersion
val generatePartials = tasks.register<Exec>("generatePartials") { val generatePartials = tasks.register<Exec>("generatePartials") {
group = "generation" group = "generation"
description = "Generate Partial classes (requests with all-nullable fields)" description = "Generate Partial classes (requests with all-nullable fields)"
@@ -37,7 +39,7 @@ val generateVersion = tasks.register<Task>("protocolVersion") {
package com.jaytux.phoebench.common package com.jaytux.phoebench.common
object ProtocolVersion { object ProtocolVersion {
const val VERSION="${rootProject.version}" const val VERSION="${version}"
} }
""".trimIndent()) """.trimIndent())
} }
@@ -42,6 +42,15 @@ sealed class ProjectEvent {
@Serializable @Serializable
data class Updated(val changes: HomeResponse.ProjectSummary) : ProjectEvent() 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 @Serializable
data class NewVersion(val label: VersionResponse) : ProjectEvent() data class NewVersion(val label: VersionResponse) : ProjectEvent()
@@ -55,5 +64,5 @@ sealed class ProjectEvent {
data class NewEntry(val entry: EntryResponse) : ProjectEvent() data class NewEntry(val entry: EntryResponse) : ProjectEvent()
@Serializable @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) data class RefreshRequest(val refreshToken: Uuid)
@Serializable @ToPartialize @Serializable @ToPartialize
data class ProjectRequest(val name: String, var isPublic: Boolean) data class ProjectRequest(val name: String, val isPublic: Boolean)
@Serializable @ToPartialize @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 @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) val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
@Serializable @Serializable
@@ -35,4 +38,10 @@ data class LogoutRequest(val refresh: Uuid)
data class UserUpdateRequest(val projectLimit: Int? = null, val isAdmin: Boolean? = null) data class UserUpdateRequest(val projectLimit: Int? = null, val isAdmin: Boolean? = null)
@Serializable @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) data class NamedID(val name: String, val id: Uuid)
@Serializable @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 @Serializable
data class ProjectSummary(val id: Uuid, val name: String, val isPublic: Boolean, val owner: NamedID) 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 @Serializable
data class ProjectResponse(val id: Uuid, val name: String, val owner: NamedID, val isPublic: Boolean, val isEditable: Boolean, data class ProjectResponse(val id: Uuid, val name: String, val owner: NamedID, val isPublic: Boolean, val isEditable: Boolean,
val usedLabels: List<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 @Serializable
data class VersionResponse(val id: Uuid, val name: String, val color: String, val meta: String) data class VersionResponse(val id: Uuid, val name: String, val color: String, val meta: String)
@Serializable @Serializable
data class EntryResponse(val id: Uuid, val labelId: Uuid, val timestamp: Instant, val warmups: List<Float>, data class EntryResponse(val id: Uuid, val 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) val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
@Serializable @Serializable
@@ -22,6 +22,7 @@ object Routes {
val handshake = ApiRoute.get<HandshakeResponse>("/", Elevation.UN_AUTH) val handshake = ApiRoute.get<HandshakeResponse>("/", Elevation.UN_AUTH)
val home = ApiRoute.get<HomeResponse>("/home", Elevation.AUTH) val home = ApiRoute.get<HomeResponse>("/home", Elevation.AUTH)
val migrate0To1 = ApiRoute.post<Migrate0To1Request, HomeResponse>("/migrate/0-1", Elevation.AUTH)
object Project { object Project {
val new = ApiRoute.post<ProjectRequest, ProjectResponse>("/project", Elevation.AUTH) val new = ApiRoute.post<ProjectRequest, ProjectResponse>("/project", Elevation.AUTH)
@@ -30,6 +31,12 @@ object Routes {
val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH) 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 { object Version {
val new = ApiRoute.post<VersionRequest, VersionResponse>("/version", Elevation.AUTH) val new = ApiRoute.post<VersionRequest, VersionResponse>("/version", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialVersionRequest>("/version", Elevation.AUTH) val update = ApiRoute.patchUuidNoRes<PartialVersionRequest>("/version", Elevation.AUTH)
+4 -1
View File
@@ -7,7 +7,10 @@ plugins {
} }
group = "com.jaytux.phoebench" group = "com.jaytux.phoebench"
version = rootProject.version version = PhoebenchVersion(1, 1, 1)
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("Server version must be at least as high as protocol/common version")
repositories { repositories {
mavenCentral() mavenCentral()
@@ -2,6 +2,8 @@ package com.jaytux.phoebench.server
import com.jaytux.phoebench.common.* import com.jaytux.phoebench.common.*
import com.jaytux.phoebench.server.db.DB 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.db.User
import com.jaytux.phoebench.server.handlers.* import com.jaytux.phoebench.server.handlers.*
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject
@@ -27,6 +29,10 @@ fun Application.module() {
DB.db DB.db
println("Entering module for PhoeBench Server version ${ServerVersion.VERSION}...") 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) { install(ContentNegotiation) {
json() json()
} }
@@ -120,12 +126,17 @@ fun Application.module() {
deleteAdmin(Routes.Auth.User.delete, AuthHandler::deleteUser) deleteAdmin(Routes.Auth.User.delete, AuthHandler::deleteUser)
getAuth(Routes.home, ProjectHandler::home) getAuth(Routes.home, ProjectHandler::home)
postAuth(Routes.migrate0To1, ProjectHandler::migrate01)
postAuth(Routes.Project.new, ProjectHandler::createProject) postAuth(Routes.Project.new, ProjectHandler::createProject)
getAuth(Routes.Project.get, ProjectHandler::getProject) getAuth(Routes.Project.get, ProjectHandler::getProject)
patchAuth(Routes.Project.update, ProjectHandler::updateProject) patchAuth(Routes.Project.update, ProjectHandler::updateProject)
deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject) deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject)
postAuth(Routes.Benchmark.new, ProjectHandler::createBenchmark)
patchAuth(Routes.Benchmark.update, ProjectHandler::updateBenchmark)
deleteAuth(Routes.Benchmark.delete, ProjectHandler::deleteBenchmark)
postAuth(Routes.Version.new, ProjectHandler::createVersion) postAuth(Routes.Version.new, ProjectHandler::createVersion)
patchAuth(Routes.Version.update, ProjectHandler::updateVersion) patchAuth(Routes.Version.update, ProjectHandler::updateVersion)
deleteAuth(Routes.Version.delete, ProjectHandler::deleteVersion) 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 { fun nowPlus(time: Int, unit: DateTimeUnit): Instant {
val now = Clock.System.now() val now = Clock.System.now()
return now.plus(time, unit, systemTZ) 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 package com.jaytux.phoebench.server.db
import com.jaytux.phoebench.server.DotEnv 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.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
object DB { object DB {
@@ -17,7 +19,7 @@ object DB {
transaction { transaction {
SchemaUtils.create(Users, Invites, RefreshTokens, Projects, Versions, Entries) 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 { try {
migration.forEach { migration.forEach {
exec(it) 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 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.core.dao.id.EntityID
import org.jetbrains.exposed.v1.dao.Entity import org.jetbrains.exposed.v1.dao.Entity
import org.jetbrains.exposed.v1.dao.EntityClass import org.jetbrains.exposed.v1.dao.EntityClass
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.uuid.Uuid 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) { class User(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, User>(Users) companion object : EntityClass<Uuid, User>(Users)
@@ -13,6 +28,8 @@ class User(id: EntityID<Uuid>) : Entity<Uuid>(id) {
var isAdmin by Users.isAdmin var isAdmin by Users.isAdmin
var isOwner by Users.isOwner var isOwner by Users.isOwner
var projectLimit by Users.projectLimit var projectLimit by Users.projectLimit
var benchmarkLimit by Users.benchmarkLimit
var projectVersion by Users.projectsVersion
val projects by Project referrersOn Projects.ownerId val projects by Project referrersOn Projects.ownerId
val sessions by RefreshToken referrersOn RefreshTokens.userId 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 isPublic by Projects.isPublic
var owner by User referencedOn Projects.ownerId var owner by User referencedOn Projects.ownerId
val labels by Version referrersOn Versions.projectId val versions by Version referrersOn Versions.projectId
val entries by Entry referrersOn Entries.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) { 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 project by Project referencedOn Entries.projectId
var version by Version referencedOn Entries.versionId 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 com.jaytux.phoebench.common.TimeUnit
import org.jetbrains.exposed.v1.core.ReferenceOption 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.core.dao.id.UuidTable
import org.jetbrains.exposed.v1.datetime.timestamp import org.jetbrains.exposed.v1.datetime.timestamp
import kotlin.time.Clock import kotlin.time.Clock
object Metas : IntIdTable() {
const val CURRENT_REVISION = 1
val revision = integer("revision").default(0)
}
object Users : UuidTable() { object Users : UuidTable() {
val username = varchar("username", 255).uniqueIndex() val username = varchar("username", 255).uniqueIndex()
val password = varchar("password", 255) val password = varchar("password", 255)
val isAdmin = bool("is_admin").default(false) val isAdmin = bool("is_admin").default(false)
val isOwner = bool("is_owner").default(false) val isOwner = bool("is_owner").default(false)
val projectLimit = integer("project_limit").default(1) val projectLimit = integer("project_limit").default(1)
val benchmarkLimit = integer("benchmark_limit").default(1)
val projectsVersion = integer("project_version").default(0)
} }
object Invites : UuidTable() { object Invites : UuidTable() {
@@ -25,6 +35,8 @@ object RefreshTokens : UuidTable() {
} }
object Projects : UuidTable() { object Projects : UuidTable() {
const val CURRENT_VERSION = 1
val ownerId = reference("owner_id", Users, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) val ownerId = reference("owner_id", Users, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val name = varchar("name", 255) val name = varchar("name", 255)
val isPublic = bool("is_public").default(false) 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() { object Versions : UuidTable() {
val label = varchar("label", 255) val label = varchar("label", 255)
val color = varchar("color", 7) val color = varchar("color", 7)
@@ -50,4 +71,15 @@ object Entries : UuidTable() {
val unit = enumeration<TimeUnit>("unit") val unit = enumeration<TimeUnit>("unit")
val input = varchar("input", 255) val input = varchar("input", 255)
val hardware = varchar("hardware", 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 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.EmptyRequest
import com.jaytux.phoebench.common.EmptyResponse import com.jaytux.phoebench.common.EmptyResponse
import com.jaytux.phoebench.common.EntryRequest import com.jaytux.phoebench.common.EntryRequest
import com.jaytux.phoebench.common.EntryResponse import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.HomeEvent import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.HomeResponse import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.Migrate0To1Request
import com.jaytux.phoebench.common.VersionRequest import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.VersionResponse import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.NamedID import com.jaytux.phoebench.common.NamedID
import com.jaytux.phoebench.common.PartialBenchmarkRequest
import com.jaytux.phoebench.common.PartialProjectRequest import com.jaytux.phoebench.common.PartialProjectRequest
import com.jaytux.phoebench.common.PartialVersionRequest import com.jaytux.phoebench.common.PartialVersionRequest
import com.jaytux.phoebench.common.ProjectEvent import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ProjectRequest import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.ProjectResponse import com.jaytux.phoebench.common.ProjectResponse
import com.jaytux.phoebench.server.Buses import com.jaytux.phoebench.server.Buses
import com.jaytux.phoebench.server.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.Entries
import com.jaytux.phoebench.server.db.Entry 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.Version
import com.jaytux.phoebench.server.db.Versions import com.jaytux.phoebench.server.db.Versions
import com.jaytux.phoebench.server.db.Project import com.jaytux.phoebench.server.db.Project
@@ -27,7 +36,12 @@ import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Transaction import org.jetbrains.exposed.v1.core.Transaction
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.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 org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -40,23 +54,44 @@ object ProjectHandler {
return when { return when {
isEditableBy(user) -> this isEditableBy(user) -> this
isPublic && !forEditing -> 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 { fun Transaction.accessibleProject(user: User, id: Uuid, forEditing: Boolean): Project {
val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound) val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound)
return project.isAccessible(user, forEditing) 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) context(trns: Transaction)
private fun Project.toResponse(user: User) = ProjectResponse( private fun Project.toResponse(user: User) = ProjectResponse(
id.value, name, NamedID(owner.username, owner.id.value), isPublic, isEditableBy(user), id.value, name, NamedID(owner.username, owner.id.value), isPublic, isEditableBy(user),
labels.orderBy(Versions.label to SortOrder.ASC).map { VersionResponse(it.id.value, it.label, it.color, it.meta) }, benchmarks.orderBy(Benchmarks.name to SortOrder.ASC).map { b -> BenchmarkResponse(
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) }) 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 { 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)) 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)) 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 { suspend fun createProject(user: User, req: ProjectRequest) = transaction {
checkMigration(user)
if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit)) if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit))
throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict) throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict)
@@ -88,11 +136,13 @@ object ProjectHandler {
} }
fun getProject(user: User, req: Uuid) = transaction { fun getProject(user: User, req: Uuid) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req, false) val proj = accessibleProject(user, req, false)
success(proj.toResponse(user)) success(proj.toResponse(user))
} }
fun updateProject(user: User, req: Pair<Uuid, PartialProjectRequest>) = transaction { fun updateProject(user: User, req: Pair<Uuid, PartialProjectRequest>) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req.first, true) val proj = accessibleProject(user, req.first, true)
val changes = req.second val changes = req.second
changes.name?.let { proj.name = it } changes.name?.let { proj.name = it }
@@ -113,6 +163,7 @@ object ProjectHandler {
} }
fun deleteProject(user: User, req: Uuid) = transaction { fun deleteProject(user: User, req: Uuid) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req, true) val proj = accessibleProject(user, req, true)
proj.delete() proj.delete()
ServerScope.launch { ServerScope.launch {
@@ -124,8 +175,57 @@ object ProjectHandler {
success(EmptyResponse()) 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 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) if(req.color.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest)
val ver = Version.new { val ver = Version.new {
label = req.name label = req.name
@@ -136,13 +236,14 @@ object ProjectHandler {
val res = VersionResponse(ver.id.value, ver.label, ver.color, ver.meta) val res = VersionResponse(ver.id.value, ver.label, ver.color, ver.meta)
ServerScope.launch { ServerScope.launch {
Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewVersion(res)) Buses.projectBus(req.benchmarkId).sendAll(ProjectEvent.NewVersion(res))
} }
success(res) success(res)
} }
fun updateVersion(user: User, req: Pair<Uuid, PartialVersionRequest>) = transaction { fun updateVersion(user: User, req: Pair<Uuid, PartialVersionRequest>) = transaction {
checkMigration(user)
val ver = Version.findById(req.first) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound) val ver = Version.findById(req.first) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
ver.project.isAccessible(user, true) ver.project.isAccessible(user, true)
val changes = req.second val changes = req.second
@@ -163,6 +264,7 @@ object ProjectHandler {
} }
fun deleteVersion(user: User, req: Uuid) = transaction { fun deleteVersion(user: User, req: Uuid) = transaction {
checkMigration(user)
val ver = Version.findById(req) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound) val ver = Version.findById(req) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
ver.project.isAccessible(user, true) ver.project.isAccessible(user, true)
ver.delete() ver.delete()
@@ -175,14 +277,15 @@ object ProjectHandler {
} }
fun createEntry(user: User, req: EntryRequest) = transaction { fun createEntry(user: User, req: EntryRequest) = transaction {
val proj = accessibleProject(user, req.projectId, true) checkMigration(user)
val entry = Entry.new { val (proj, bench) = accessibleBenchmark(user, req.benchmarkId, true)
val entry = EntryV2.new {
version = when(val l = Version.findById(req.version)) { version = when(val l = Version.findById(req.version)) {
null -> throw RouteError("Invalid version ID.", HttpStatusCode.NotFound) 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) is Version if l.projectId.value != proj.id.value -> throw RouteError("Version is attached to a different project.", HttpStatusCode.Conflict)
else -> l else -> l
} }
project = proj benchmarkId = bench.id
measurements = req.measurements measurements = req.measurements
timestamp = req.timestamp timestamp = req.timestamp
warmups = req.warmups warmups = req.warmups
@@ -191,7 +294,8 @@ object ProjectHandler {
hardware = req.hardware 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 { ServerScope.launch {
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response)) Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response))
} }
@@ -199,12 +303,82 @@ object ProjectHandler {
} }
fun deleteEntry(user: User, req: Uuid) = transaction { fun deleteEntry(user: User, req: Uuid) = transaction {
val entry = Entry.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound) checkMigration(user)
entry.project.isAccessible(user, true) val entry = EntryV2.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
val (proj, bench) = entry.benchmark.isAccessible(user, true)
entry.delete() entry.delete()
ServerScope.launch { 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()) 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())
}
} }