diff --git a/.gitignore b/.gitignore index f1810b8..ff409ab 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,10 @@ bin/ .DS_Store *.db +*.bak* .env +.env.* +*.sql .idea/ artifacts/ diff --git a/build.gradle.kts b/build.gradle.kts index 8e87ec6..693146e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import PhoebenchVersion + plugins { alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false @@ -12,4 +14,4 @@ repositories { mavenCentral() } -version = "1.0.0" \ No newline at end of file +version = PhoebenchVersion(1, 1, 0, "") \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..b22ed73 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/PhoebenchVersion.kt b/buildSrc/src/main/kotlin/PhoebenchVersion.kt new file mode 100644 index 0000000..fc5af7b --- /dev/null +++ b/buildSrc/src/main/kotlin/PhoebenchVersion.kt @@ -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" +} \ No newline at end of file diff --git a/clients/cli/build.gradle.kts b/clients/cli/build.gradle.kts index 4ac6113..f98446a 100644 --- a/clients/cli/build.gradle.kts +++ b/clients/cli/build.gradle.kts @@ -8,7 +8,10 @@ plugins { } 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 generateVersion = tasks.register("serverVersion") { diff --git a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/CLI.kt b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/CLI.kt index b1bc236..d03f3e6 100644 --- a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/CLI.kt +++ b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/CLI.kt @@ -101,10 +101,11 @@ object CLI { override fun nestedCommands(): Array = fromReflection() sealed interface IProjectIdentification - sealed interface ILabelIdentification + sealed interface IVersionIdentification + sealed interface IBenchmarkIdentification data class ProjectName(val user: String, val project: String) : IProjectIdentification - data class VersionName(val name: String): ILabelIdentification - data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification + data class Name(val name: String): IVersionIdentification, IBenchmarkIdentification + data class ID(val id: Uuid) : IProjectIdentification, IVersionIdentification, IBenchmarkIdentification sealed interface IData { fun toList(): List @@ -184,12 +185,24 @@ object CLI { override fun run() = ProjectHandlers.newVersion(name, color, parent.finder, meta) } + @Suppress("unused") + class AddBenchmark : CliktCommand(name = "add-benchmark") { + val parent by requireObject() + val name by option("--name", help = "Set the version's name (*)") + + override fun run() = ProjectHandlers.newBenchmark(name, parent.finder) + } + @Suppress("unused") class AddData : CliktCommand(name = "add-data") { val parent by requireObject() - val version by mutuallyExclusiveOptions( + val version by mutuallyExclusiveOptions( 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( + option("--benchmark-id", "--bench-id", help = "Set the benchmark by UUID.").convert { ID(Uuid.parse(it)) }, + option("--benchmark", "--bench", help = "Set the benchmark by name.").convert { Name(it) } ).single() val warmup by mutuallyExclusiveOptions>( 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 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) } } diff --git a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/ProjectHandlers.kt b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/ProjectHandlers.kt index 299fcb6..a6e6b28 100644 --- a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/ProjectHandlers.kt +++ b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/ProjectHandlers.kt @@ -1,6 +1,7 @@ package com.jaytux.phoebench.clients.cli import com.jaytux.phoebench.clients.cli.CLI.Commands.Project.ProjectName +import com.jaytux.phoebench.common.BenchmarkRequest import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.EmptyRequest import com.jaytux.phoebench.common.EntryRequest @@ -29,6 +30,7 @@ object ProjectHandlers { Client.callRoute(Routes.home, EmptyRequest()) }.fold({ err -> System.err.println("Could not get projects list: ${err.msg}") + exitProcess(-1) }) { System.err.println("Logged in as ${it.username}") (it.ownProjects + it.publicProjects).toSet().sortedBy { p -> p.name }.forEach { project -> @@ -65,18 +67,22 @@ object ProjectHandlers { } }.fold({ System.err.println("Failed to get project details: ${it.msg}") + exitProcess(-1) }) { println("Project ${it.owner.name}/${it.name} [${it.id}]:") - println("${it.usedLabels.size} versions:") - it.usedLabels.forEach { v -> + println("${it.versions.size} versions:") + it.versions.forEach { v -> println(" - [${v.id}] VERSION ${v.name} (with color ${v.color}); metadata: ${v.meta}") } - val map = it.usedLabels.associateBy { l -> l.id } + val map = it.versions.associateBy { l -> l.id } - println("\n${it.entries.size} entries:") - it.entries.forEach { e -> - println(" - [${e.id}] ENTRY versioned ${map[e.labelId]?.name ?: ""} at ${e.timestamp.fmt()} " + - "(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})") + println("\n${it.benchmarks.size} benchmarks:") + it.benchmarks.forEach { b -> + println(" - [${b.id}] ${b.name} with ${b.entries.size} entries:") + b.entries.forEach { e -> + println(" - [${e.id}] ENTRY versioned ${map[e.versionId]?.name ?: ""} at ${e.timestamp.fmt()} " + + "(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})") + } } } } @@ -87,6 +93,7 @@ object ProjectHandlers { Client.callRoute(Routes.Project.new, ProjectRequest(useName, isPublic)) }.fold({ System.err.println("Failed to create project '$useName': ${it.msg}") + exitProcess(-1) }) { println("Project ${it.owner.name}/${it.name} [${it.id}] created.") } @@ -111,8 +118,25 @@ object ProjectHandlers { } }.fold({ System.err.println("Could not create version: ${it.msg}") + exitProcess(-1) }) { - println("Label ${it.name} (${it.color}) [${it.id}] created.") + println("Version ${it.name} (${it.color}) [${it.id}] created.") + } + } + + fun newBenchmark(name: String?, project: CLI.Commands.Project.IProjectIdentification?) { + val id = ensureProjectIdentification(project) + val useName = name.maybePrompt("version name") { it } + + tryAuthenticated { + id.toId().bind { projectId -> + Client.callRoute(Routes.Benchmark.new, BenchmarkRequest(useName, projectId)) + } + }.fold({ + System.err.println("Could not create benchmark: ${it.msg}") + exitProcess(-1) + }) { + println("Benchmark ${it.name} [${it.id}] created.") } } @@ -129,14 +153,18 @@ object ProjectHandlers { fun newData( project: CLI.Commands.Project.IProjectIdentification?, - label: CLI.Commands.Project.ILabelIdentification?, + benchmark: CLI.Commands.Project.IBenchmarkIdentification?, + label: CLI.Commands.Project.IVersionIdentification?, warmup: CLI.Commands.Project.IData?, measurement: CLI.Commands.Project.IData?, unit: TimeUnit?, input: String?, hardware: String? ) { val projectId = ensureProjectIdentification(project) val versionId = label.maybePrompt("version name") { - CLI.Commands.Project.VersionName(it) + CLI.Commands.Project.Name(it) + } + val benchmarkId = benchmark.maybePrompt("benchmark name") { + CLI.Commands.Project.Name(it) } val warmupData = warmup.ensure("warmup").toList() val measureData = measurement.ensure("measurements").toList() @@ -160,19 +188,30 @@ object ProjectHandlers { tryAuthenticated { projectId.toId().bind { pId -> - when(versionId) { - is CLI.Commands.Project.ID -> versionId.id.value() - is CLI.Commands.Project.VersionName -> Client.callRoute(Routes.Project.get, pId).bind { - it.usedLabels.firstOrNull { l -> l.name == versionId.name }?.id?.value() - ?: ErrorResponse("Version ${versionId.name} does not exist in this project.").error() + Client.callRoute(Routes.Project.get, pId).bind { p -> + when (versionId) { + is CLI.Commands.Project.ID -> versionId.id.value() + is CLI.Commands.Project.Name -> p.versions.firstOrNull { l -> l.name == versionId.name }?.id?.value() + ?: ErrorResponse("Version ${versionId.name} does not exist in this project.").error() + }.bind { vId -> + when (benchmarkId) { + is CLI.Commands.Project.ID -> benchmarkId.id.value() + is CLI.Commands.Project.Name -> p.benchmarks.firstOrNull { b -> b.name == benchmarkId.name }?.id?.value() + ?: ErrorResponse("Benchmark ${benchmarkId.name} does not exist in this project.").error() + }.map { it to vId } } - }.map { pId to it } - }.bind { (pId, vId) -> + } + }.bind { (bId, vId) -> Client.callRoute(Routes.Entry.new, EntryRequest( - vId, Clock.System.now(), pId, + vId, Clock.System.now(), bId, warmupData, measureData, timeUnit, useInput, useHardware )) } + }.fold({ + System.err.println("Failed to add data: ${it.msg}") + exitProcess(-1) + }) { + println("Data added successfully.") } } } \ No newline at end of file diff --git a/clients/compose/build.gradle.kts b/clients/compose/build.gradle.kts index d595efd..b5ed720 100644 --- a/clients/compose/build.gradle.kts +++ b/clients/compose/build.gradle.kts @@ -13,7 +13,10 @@ plugins { alias(libs.plugins.buildconfig) } -version = "1.0.1" +version = rootProject.version as PhoebenchVersion + +if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion)) + throw GradleException("UI Clients version must be at least as high as protocol/common version") val versionDirectory = layout.buildDirectory.dir("generated/sources/version") val generateVersion = tasks.register("clientVersion") { @@ -128,7 +131,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "com.jaytux.phoebench.clients" - packageVersion = version.toString() + packageVersion = (version as PhoebenchVersion).safeString() jvmArgs += listOf("--enable-native-access=ALL-UNNAMED") includeAllModules = true diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.kt index 7156754..24af67d 100644 --- a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.kt @@ -1,9 +1,13 @@ package com.jaytux.phoebench.clients import androidx.compose.runtime.Composable +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTransferData import androidx.compose.ui.platform.ClipEntry import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig +import kotlinx.serialization.KSerializer +import kotlinx.serialization.serializer import kotlin.uuid.Uuid interface IStore { @@ -26,4 +30,12 @@ expect suspend fun String.toClipEntry(): ClipEntry expect fun format(f: Float): String @Composable -expect fun ServerSelect(server: String, onUpdate: (String) -> Unit) \ No newline at end of file +expect fun ServerSelect(server: String, onUpdate: (String) -> Unit) + +inline fun drag(data: T) = drag(data, serializer()) + +expect fun drag(data: T, serializer: KSerializer): DragAndDropTransferData + +inline fun drop(data: DragAndDropEvent): T? = drop(data, serializer()) + +expect fun drop(data: DragAndDropEvent, serializer: KSerializer): T? \ No newline at end of file diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/Util.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/Util.kt index 94436a4..98783ee 100644 --- a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/Util.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/Util.kt @@ -85,8 +85,21 @@ inline fun > List.insort(elem: T, crossinline sortBy: (T return toMutableList().apply { add(index, elem) } } -fun List.geomean(): Float { - return (size.toFloat() / sumOf { 1.0 / it.toDouble() }).toFloat() +inline fun > List.insortIdx(elem: T, crossinline sortBy: (T) -> X): Pair, Int?> { + val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy) + if(insertionPoint >= 0) return this to null + val index = -insertionPoint - 1 + + return toMutableList().apply { add(index, elem) } to index +} + +inline fun List.replace(crossinline finder: (T) -> Boolean, crossinline update: (T) -> T): List { + val idx = indexOfFirst { finder(it) } + if(idx == -1) return this + + val res = ArrayList(this) + res[idx] = update(res[idx]) + return res } infix fun Pair.app(t3: T3) = Triple(first, second, t3) @@ -122,4 +135,6 @@ fun List>.unzip(): Tuple4, List } return l1 to l2 app l3 app l4 -} \ No newline at end of file +} + +fun List.removeAt(idx: Int): List = subList(0, idx) + subList(idx + 1, size) \ No newline at end of file diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt index 64ef0c9..61c2c6f 100644 --- a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt @@ -17,6 +17,7 @@ import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.HomeEvent import com.jaytux.phoebench.common.HomeResponse import com.jaytux.phoebench.common.InviteListResponse +import com.jaytux.phoebench.common.Migrate0To1Request import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.UserListResponse import com.jaytux.phoebench.common.value @@ -40,11 +41,14 @@ class HomeVM( private val _users = mutableStateOf(listOf()) private val _invites = mutableStateOf(listOf()) + private val _stepsNeeded = mutableStateOf(listOf()) + val username = _username.immutable() val isAdmin = _isAdmin.immutable() val projectLimit = _projectLimit.immutable() val ownProjects = _ownProjects.immutable() val publicProjects = _publicProjects.immutable() + val stepsNeeded = _stepsNeeded.immutable() val invites = _invites.immutable() val users = _users.immutable() @@ -81,17 +85,22 @@ class HomeVM( withScope { resetAdmin() _repo.getHome().snackOr { - println("Received home response $it") - _username.value = it.username - _isAdmin.value = it.isAdmin - _projectLimit.value = it.projectLimit - _ownProjects.value = it.ownProjects.sortedBy { p -> p.name } - _publicProjects.value = it.publicProjects.sortedBy { p -> p.name } - refreshAdmin() + refreshFrom(it) } } } + private suspend fun refreshFrom(it: HomeResponse) { + println("Received home response $it") + _username.value = it.username + _isAdmin.value = it.isAdmin + _projectLimit.value = it.projectLimit + _ownProjects.value = it.ownProjects.sortedBy { p -> p.name } + _publicProjects.value = it.publicProjects.sortedBy { p -> p.name } + _stepsNeeded.value = it.neededMigrations + refreshAdmin() + } + fun refreshAdmin() { if(_isAdmin.value) { _adminJob = withScope { @@ -212,4 +221,12 @@ class HomeVM( } } } + + fun migrate(v0to1: List) { + withScope { + _repo.migrate0To1(v0to1).snackOr { + refreshFrom(it) + } + } + } } \ No newline at end of file diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IHomeRepo.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IHomeRepo.kt index 0426960..4a451eb 100644 --- a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IHomeRepo.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IHomeRepo.kt @@ -9,6 +9,7 @@ import com.jaytux.phoebench.common.HomeResponse import com.jaytux.phoebench.common.InviteListResponse import com.jaytux.phoebench.common.InviteRequest import com.jaytux.phoebench.common.LogoutRequest +import com.jaytux.phoebench.common.Migrate0To1Request import com.jaytux.phoebench.common.ProjectRequest import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.UserListResponse @@ -31,6 +32,8 @@ interface IHomeRepo { suspend fun newProject(name: String, isPublic: Boolean): Either + suspend fun migrate0To1(conversions: List): Either + companion object { class Default(private val _client: Client) : IHomeRepo { override suspend fun getHome(): Either = @@ -62,6 +65,9 @@ interface IHomeRepo { override suspend fun newProject(name: String, isPublic: Boolean): Either = _client.callRoute(Routes.Project.new, ProjectRequest(name, isPublic)).ignoreValue() + + override suspend fun migrate0To1(conversions: List): Either = + _client.callRoute(Routes.migrate0To1, Migrate0To1Request(conversions)) } fun default(client: Client) = Default(client) diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IProjectRepo.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IProjectRepo.kt index 1db0d8c..572e857 100644 --- a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IProjectRepo.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IProjectRepo.kt @@ -4,10 +4,13 @@ import androidx.compose.ui.graphics.Color import com.jaytux.phoebench.clients.Client import com.jaytux.phoebench.clients.hexString import com.jaytux.phoebench.clients.ignoreValue +import com.jaytux.phoebench.common.BenchmarkRequest +import com.jaytux.phoebench.common.BenchmarkResponse import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.EntryRequest import com.jaytux.phoebench.common.EntryResponse import com.jaytux.phoebench.common.ErrorResponse +import com.jaytux.phoebench.common.PartialBenchmarkRequest import com.jaytux.phoebench.common.VersionRequest import com.jaytux.phoebench.common.VersionResponse import com.jaytux.phoebench.common.PartialProjectRequest @@ -23,11 +26,15 @@ interface IProjectRepo { suspend fun update(name: String? = null, isPublic: Boolean? = null): Either suspend fun delete(): Either + suspend fun newBenchmark(name: String): Either + suspend fun updateBenchmark(id: Uuid, name: String? = null): Either + suspend fun deleteBenchmark(id: Uuid): Either + suspend fun newVersion(name: String, color: Color, meta: String): Either suspend fun updateVersion(id: Uuid, name: String? = null, color: Color? = null, meta: String? = null): Either suspend fun deleteVersion(id: Uuid): Either - suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List, measurements: List, + suspend fun newEntry(benchmark: Uuid, label: Uuid, timestamp: Instant, warmups: List, measurements: List, unit: TimeUnit, input: String, hardware: String): Either suspend fun deleteEntry(id: Uuid): Either @@ -42,6 +49,15 @@ interface IProjectRepo { override suspend fun delete(): Either = _client.callRoute(Routes.Project.delete, _projectId).ignoreValue() + override suspend fun newBenchmark(name: String): Either = + _client.callRoute(Routes.Benchmark.new, BenchmarkRequest(name, _projectId)) + + override suspend fun updateBenchmark(id: Uuid, name: String?): Either = + _client.callRoute(Routes.Benchmark.update, id to PartialBenchmarkRequest(name = name)).ignoreValue() + + override suspend fun deleteBenchmark(id: Uuid): Either = + _client.callRoute(Routes.Benchmark.delete, id).ignoreValue() + override suspend fun newVersion(name: String, color: Color, meta: String): Either = _client.callRoute(Routes.Version.new, VersionRequest(name, color.hexString(), meta, _projectId)) @@ -51,10 +67,10 @@ interface IProjectRepo { override suspend fun deleteVersion(id: Uuid): Either = _client.callRoute(Routes.Version.delete, id).ignoreValue() - override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List, + override suspend fun newEntry(benchmark: Uuid, label: Uuid, timestamp: Instant, warmups: List, measurements: List, unit: TimeUnit, input: String, hardware: String ): Either = - _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 = _client.callRoute(Routes.Entry.delete, id).ignoreValue() diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt index 320ef78..8eaf613 100644 --- a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt @@ -11,7 +11,10 @@ import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr import com.jaytux.phoebench.clients.hexString import com.jaytux.phoebench.clients.immutable import com.jaytux.phoebench.clients.insort +import com.jaytux.phoebench.clients.insortIdx +import com.jaytux.phoebench.clients.replace import com.jaytux.phoebench.clients.withScope +import com.jaytux.phoebench.common.BenchmarkResponse import com.jaytux.phoebench.common.EntryResponse import com.jaytux.phoebench.common.VersionResponse import com.jaytux.phoebench.common.ProjectEvent @@ -57,9 +60,20 @@ class ProjectVM( data class Entry(val id: Uuid, val label: Version, val timeStamp: Instant, val warmups: List, val measurements: List, val nativeUnit: TimeUnit, val input: String, val hardware: String) { companion object { - fun fromResponse(it: EntryResponse, map: Map) = Entry( - it.id, map[it.labelId] ?: Version.invalid, it.timestamp, + inline fun fromResponse(it: EntryResponse, map: Map, addInput: (String) -> Unit, addHardware: (String) -> Unit) = Entry( + it.id, map[it.versionId] ?: Version.invalid, it.timestamp, it.warmups, it.measurements, it.unit, it.input, it.hardware + ).also { addInput(it.input); addHardware(it.hardware) } + } + } + + data class Benchmark(val id: Uuid, val name: String, val entries: List) { + 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, addInput: (String) -> Unit, addHardware: (String) -> Unit) = Benchmark( + it.id, it.name, it.entries.map { er -> Entry.fromResponse(er, map, addInput, addHardware) } ) } } @@ -69,7 +83,8 @@ class ProjectVM( private val _public = mutableStateOf(false) private val _editable = mutableStateOf(false) private val _labels = mutableStateOf(mapOf()) - private val _entries = mutableStateOf(listOf()) + private val _benchmarks = mutableStateOf(listOf()) + private val _currentBenchmark = mutableStateOf(0) private val _inputs = mutableStateOf(setOf()) private val _hardware = mutableStateOf(setOf()) @@ -78,7 +93,8 @@ class ProjectVM( val public = _public.immutable() val editable = _editable.immutable() val versions = _labels.immutable() - val entries = _entries.immutable() + val benchmarks = _benchmarks.immutable() + val currentBenchmark = _currentBenchmark.immutable() val inputs = _inputs.immutable() val hardware = _hardware.immutable() @@ -102,14 +118,10 @@ class ProjectVM( _owner.value = it.owner.name _public.value = it.isPublic _editable.value = it.isEditable - _labels.value = it.usedLabels.associate { l -> l.id to Version.fromResponse(l) } + _labels.value = it.versions.associate { l -> l.id to Version.fromResponse(l) } val localInputs = mutableSetOf() val localHardware = mutableSetOf() - _entries.value = it.entries.map { e -> - localInputs += e.input - localHardware += e.hardware - Entry.fromResponse(e, _labels.value) - } + _benchmarks.value = it.benchmarks.map { b -> Benchmark.fromResponse(b, _labels.value, localInputs::add, localHardware::add) } _inputs.value = localInputs _hardware.value = localHardware } @@ -119,13 +131,12 @@ class ProjectVM( private fun handleProjectEvent(event: ProjectEvent) { when(event) { ProjectEvent.Deleted -> back() - is ProjectEvent.EntryDeleted -> _entries.value = _entries.value.filter { it.id != event.id } + is ProjectEvent.EntryDeleted -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmarkId }) { it.remove(event.id) } is ProjectEvent.VersionChanged -> _labels.value += (event.label.id to Version.fromResponse(event.label)) is ProjectEvent.VersionDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id } is ProjectEvent.NewEntry -> { - _entries.value = _entries.value.insort(Entry.fromResponse(event.entry, _labels.value), Entry::timeStamp) - _inputs.value += event.entry.input - _hardware.value += event.entry.hardware + _benchmarks.value = _benchmarks.value.replace({ it.id == event.entry.benchmarkId }) { it.add(Entry.fromResponse(event.entry, _labels.value, + { input -> _inputs.value += input }, { hw -> _hardware.value += hw })) } } is ProjectEvent.NewVersion -> _labels.value += (event.label.id to Version.fromResponse(event.label)) is ProjectEvent.Updated -> { @@ -133,6 +144,23 @@ class ProjectVM( _owner.value = event.changes.owner.name _public.value = event.changes.isPublic } + + is ProjectEvent.BenchmarkChanged -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmark.id }) { it.copy(name = event.benchmark.name) } + is ProjectEvent.BenchmarkDeleted -> { + val idx = _benchmarks.value.indexOfFirst { it.id == event.id } + if(idx == _currentBenchmark.value) _currentBenchmark.value = 0 + else if(idx < _currentBenchmark.value) _currentBenchmark.value -= 1 + _benchmarks.value = _benchmarks.value.filter { it.id != event.id } + } + is ProjectEvent.NewBenchmark -> { + val (updated, idx) = _benchmarks.value.insortIdx( + Benchmark.fromResponse(event.benchmark, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw }) + ) { it.name } + if(idx != null) { + if(idx < _currentBenchmark.value) _currentBenchmark.value += 1 + } + _benchmarks.value = updated + } } } @@ -183,24 +211,69 @@ class ProjectVM( } } - fun mkEntry(label: Version, warmups: List, measurements: List, unit: TimeUnit, input: String, hardware: String) { + fun mkBenchmark(name: String) { withScope { - _repo.newEntry(label.id, Clock.System.now(), warmups, measurements, unit, input, hardware).snackOr { + _repo.newBenchmark(name).snackOr { bench -> if(_job == null) { - _entries.value += Entry.fromResponse(it, _labels.value) - _inputs.value += input - _hardware.value += hardware + val (updated, idx) = _benchmarks.value.insortIdx( + Benchmark.fromResponse(bench, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw }) + ) { it.name } + _currentBenchmark.value?.let { curr -> + if(idx != null) { + if(idx < curr) _currentBenchmark.value = curr + 1 + } + } + _benchmarks.value = updated } } } } - fun deleteEntry(id: Uuid) { + fun updateBenchmark(id: Uuid, name: String?) { withScope { - _repo.deleteEntry(id).snackOr { - if(_job == null) - _entries.value = _entries.value.filter { it.id != id } + val old = _benchmarks.value.find { it.id == id } ?: return@withScope + _repo.updateVersion(id, name).snackOr { + if(_job == null) { + _benchmarks.value = _benchmarks.value.replace({ it.id == id }) { it.copy(name = name ?: old.name) } + } } } } + + fun deleteBenchmark(id: Uuid) { + withScope { + _repo.deleteBenchmark(id).snackOr { + if(_job == null) { + val idx = _benchmarks.value.indexOfFirst { it.id == id } + if(idx == _currentBenchmark.value) _currentBenchmark.value = 0 + else if(idx < _currentBenchmark.value) _currentBenchmark.value -= 1 + _benchmarks.value = _benchmarks.value.filter { it.id != id } + } + } + } + } + + fun mkEntry(label: Version, warmups: List, measurements: List, 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) + } } \ No newline at end of file diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt index fa27767..7825a91 100644 --- a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt @@ -320,7 +320,19 @@ fun ConfirmLogoutDialog(onCancel: () -> Unit, onLogout: (everywhere: Boolean) -> @Composable fun HomeView(onSelectProject: (Uuid) -> Unit) { val vm = viewModel { HomeVM() } + val todo by vm.stepsNeeded + todo.firstOrNull()?.let { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + when (it) { + is HomeResponse.NeededMigration.Migrate0To1 -> Migrate0To1View(vm, it) + } + } + } ?: HomePanel(vm, onSelectProject) +} + +@Composable +fun HomePanel(vm: HomeVM, onSelectProject: (Uuid) -> Unit) { val auth = AuthProvider.get() val username by vm.username val isAdmin by vm.isAdmin diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/MigrateView.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/MigrateView.kt new file mode 100644 index 0000000..57c61a9 --- /dev/null +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/MigrateView.kt @@ -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>>()) } + + 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(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(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()) + } + } + } +} \ No newline at end of file diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt index 1c75d49..a7aa118 100644 --- a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt @@ -50,13 +50,13 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) { val owner by vm.owner val public by vm.public val editable by vm.editable - val labels by vm.versions + val versions by vm.versions + val benchmarks by vm.benchmarks + val currentBenchmark by vm.currentBenchmark var editing by remember { mutableStateOf(false) } var deleting by remember { mutableStateOf(false) } - var addOpen by remember { mutableStateOf(false) } - var addingLabel by remember { mutableStateOf(false) } - var addingData by remember { mutableStateOf(false) } + var addingBenchmark by remember { mutableStateOf(false) } Column(Modifier.padding(all = 15.dp)) { Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) { @@ -73,9 +73,79 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) { } owner?.let { Text("${if(public) "Public" else "Private"} project by user $it") } Spacer(Modifier.height(15.dp)) - Surface(Modifier.fillMaxSize().padding(8.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) { + 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, benchmarks: List, 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) } + + 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) } + Row(Modifier.clickable { isOpen = !isOpen }, verticalAlignment = Alignment.CenterVertically) { + Text(currentBenchmark.name, style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.width(10.dp)) + Icon(Lucide.ChevronDown, "Select benchmark...") + } + + DropdownMenu(isOpen, { isOpen = false }) { + benchmarks.forEach { b -> + DropdownMenuItem({ Text(b.name, style = MaterialTheme.typography.headlineSmall) }, { vm.selectBenchmark(b.id); isOpen = false }) + } + } + } + + IconButton({ vm.nextBenchmark() }, enabled = currentIndex < benchmarks.size - 1) { + Icon(Lucide.ChevronRight, "Previous benchmark") + } + } + } Box(Modifier.fillMaxSize().padding(15.dp)) { - ProjectPlotArea(vm) + ProjectPlotArea(vm, currentBenchmark) if(editable) { if(addOpen) { @@ -88,9 +158,22 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) { if (addOpen) { var lblWidth by mutableStateOf(1000.dp) var dataWidth by mutableStateOf(1000.dp) + var benchWidth by mutableStateOf(1000.dp) val density = LocalDensity.current - Column(Modifier.width(maxOf(lblWidth, dataWidth)), 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 { lblWidth = with(density) { it.size.width.toDp() } }, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) { @@ -126,14 +209,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 -> vm.mkLabel(name, color, meta) addOpen = false @@ -142,7 +217,7 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) { val usedInputs by vm.inputs val usedHardware by vm.hardware - if(addingData) AddEntryDialog(labels, usedInputs, usedHardware, + if(addingData) AddEntryDialog(versions, usedInputs, usedHardware, onCancel = { addingData = false; addOpen = false }, onAddLbl = { name, lbl, meta -> vm.mkLabel(name, lbl, meta) } ) { label, warmups, measurements, unit, input, hardware -> @@ -159,7 +234,7 @@ fun ProjectEditDialog(name: String, public: Boolean, onCancel: () -> Unit, onSav Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) { Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) { - Text("Create project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) + Text("Modify project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) OutlinedTextField(updName, { updName = it }, Modifier.fillMaxWidth(), label = { Text("Name") }) Spacer(Modifier.height(10.dp)) Row(Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically) { @@ -193,6 +268,25 @@ 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 AddVersionDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color, meta: String) -> Unit) { Dialog(onDismissRequest = onCancel) { @@ -408,8 +502,8 @@ fun EditVersionDialog(current: ProjectVM.Version, onCancel: () -> Unit, onUpdate } @Composable -fun ProjectPlotArea(vm: ProjectVM){ - val entries by vm.entries +fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) { + val entries = benchmark.entries val versions by vm.versions val editable by vm.editable val allInputs by vm.inputs diff --git a/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt b/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt index 842f55e..d467460 100644 --- a/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt +++ b/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt @@ -2,6 +2,11 @@ package com.jaytux.phoebench.clients import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTransferAction +import androidx.compose.ui.draganddrop.DragAndDropTransferData +import androidx.compose.ui.draganddrop.DragAndDropTransferable +import androidx.compose.ui.draganddrop.awtTransferable import androidx.compose.ui.platform.ClipEntry import com.github.javakeyring.Keyring import com.jaytux.phoebench.clients.ui.DefaultServerSelect @@ -10,6 +15,7 @@ import io.ktor.client.engine.okhttp.* import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json import kotlinx.serialization.serializer +import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.StringSelection import kotlin.uuid.Uuid @@ -62,4 +68,17 @@ actual suspend fun String.toClipEntry(): ClipEntry = actual fun format(f: Float): String = String.format("%.03f", f) @Composable -actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate) \ No newline at end of file +actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate) + +@OptIn(ExperimentalComposeUiApi::class) +actual fun drag(data: T, serializer: KSerializer): DragAndDropTransferData = + DragAndDropTransferData( + DragAndDropTransferable(StringSelection(Json.encodeToString(serializer, data))), + supportedActions = listOf(DragAndDropTransferAction.Move) + ) + +@OptIn(ExperimentalComposeUiApi::class) +actual fun drop(data: DragAndDropEvent, serializer: KSerializer): T? = + if(data.awtTransferable.isDataFlavorSupported(DataFlavor.stringFlavor)) + Json.decodeFromString(serializer, data.awtTransferable.getTransferData(DataFlavor.stringFlavor) as String) + else null \ No newline at end of file diff --git a/clients/compose/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt b/clients/compose/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt index 035e4fe..857c7f5 100644 --- a/clients/compose/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt +++ b/clients/compose/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt @@ -1,6 +1,10 @@ package com.jaytux.phoebench.clients import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTransferData +import androidx.compose.ui.draganddrop.domDataTransferOrNull import androidx.compose.ui.platform.ClipEntry import com.jaytux.phoebench.clients.ui.constantServerSelect import io.ktor.client.* @@ -10,8 +14,10 @@ import kotlinx.coroutines.await import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.serializer +import org.w3c.dom.DataTransfer import kotlin.uuid.Uuid object WasmJsStore : IStore { @@ -74,4 +80,19 @@ lateinit var config: Config @Composable actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) { constantServerSelect(config.server)(server, onUpdate) -} \ No newline at end of file +} + +@OptIn(ExperimentalWasmJsInterop::class) +@JsFun("() => new DataTransfer()") +private external fun createDataTransfer(): DataTransfer + +@OptIn(ExperimentalComposeUiApi::class) +actual fun drag(data: T, serializer: KSerializer): DragAndDropTransferData = DragAndDropTransferData( + createDataTransfer().also { it.setData("application/json", Json.encodeToString(serializer, data)) } +) + +@OptIn(ExperimentalComposeUiApi::class) +actual fun drop(data: DragAndDropEvent, serializer: KSerializer): T? = + data.transferData?.domDataTransferOrNull?.getData("application/json")?.let { + Json.decodeFromString(serializer, it) + } \ No newline at end of file diff --git a/common/build.gradle.kts b/common/build.gradle.kts index bfb6f95..4051413 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -11,6 +11,8 @@ val partialsDirectory = layout.buildDirectory.dir("generated/sources/partials") val versionDirectory = layout.buildDirectory.dir("generated/sources/version") val requestsDirectory = layout.projectDirectory.dir("src/commonMain/kotlin/com/jaytux/phoebench/common/") +version = rootProject.version as PhoebenchVersion + val generatePartials = tasks.register("generatePartials") { group = "generation" description = "Generate Partial classes (requests with all-nullable fields)" @@ -37,7 +39,7 @@ val generateVersion = tasks.register("protocolVersion") { package com.jaytux.phoebench.common object ProtocolVersion { - const val VERSION="${rootProject.version}" + const val VERSION="${version}" } """.trimIndent()) } diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Events.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Events.kt index 76911ad..cc06f5d 100644 --- a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Events.kt +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Events.kt @@ -42,6 +42,15 @@ sealed class ProjectEvent { @Serializable data class Updated(val changes: HomeResponse.ProjectSummary) : ProjectEvent() + @Serializable + data class NewBenchmark(val benchmark: BenchmarkResponse) : ProjectEvent() + + @Serializable + data class BenchmarkChanged(val benchmark: BenchmarkSummary) : ProjectEvent() + + @Serializable + data class BenchmarkDeleted(val id: Uuid) : ProjectEvent() + @Serializable data class NewVersion(val label: VersionResponse) : ProjectEvent() @@ -55,5 +64,5 @@ sealed class ProjectEvent { data class NewEntry(val entry: EntryResponse) : ProjectEvent() @Serializable - data class EntryDeleted(val id: Uuid) : ProjectEvent() + data class EntryDeleted(val id: Uuid, val benchmarkId: Uuid) : ProjectEvent() } \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Requests.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Requests.kt index 5bba870..fb04120 100644 --- a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Requests.kt +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Requests.kt @@ -19,13 +19,16 @@ class EmptyRequest data class RefreshRequest(val refreshToken: Uuid) @Serializable @ToPartialize -data class ProjectRequest(val name: String, var isPublic: Boolean) +data class ProjectRequest(val name: String, val isPublic: Boolean) @Serializable @ToPartialize -data class VersionRequest(val name: String, val color: String, val meta: String, val projectId: Uuid) +data class BenchmarkRequest(val name: String, val projectId: Uuid) @Serializable @ToPartialize -data class EntryRequest(val version: Uuid, val timestamp: Instant, val projectId: Uuid, val warmups: List, +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, val measurements: List, val unit: TimeUnit, val input: String, val hardware: String) @Serializable @@ -35,4 +38,10 @@ data class LogoutRequest(val refresh: Uuid) data class UserUpdateRequest(val projectLimit: Int? = null, val isAdmin: Boolean? = null) @Serializable -data class InviteRequest(val asAdmin: Boolean) \ No newline at end of file +data class InviteRequest(val asAdmin: Boolean) + +@Serializable +data class Migrate0To1Request(val conversions: List) { + @Serializable + data class MigrateProject(val newProjectName: String, val isPublic: Boolean, val oldProjectsAsBenchmarks: List) +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Responses.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Responses.kt index ba5ec47..a12f30f 100644 --- a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Responses.kt +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Responses.kt @@ -32,20 +32,35 @@ data class UserListResponse(val users: List) { data class NamedID(val name: String, val id: Uuid) @Serializable -data class HomeResponse(val username: String, val isAdmin: Boolean, val projectLimit: Int, val ownProjects: List, val publicProjects: List) { +data class HomeResponse(val username: String, val isAdmin: Boolean, val projectLimit: Int, val ownProjects: List, val publicProjects: List, val neededMigrations: List) { @Serializable data class ProjectSummary(val id: Uuid, val name: String, val isPublic: Boolean, val owner: NamedID) + + @Serializable + sealed class NeededMigration { + @Serializable + data class Migrate0To1(val projects: List) : NeededMigration() { + @Serializable + data class OldProject(val id: Uuid, val name: String, val versions: List) + } + } } @Serializable data class ProjectResponse(val id: Uuid, val name: String, val owner: NamedID, val isPublic: Boolean, val isEditable: Boolean, - val usedLabels: List, val entries: List) + val benchmarks: List, val versions: List) + +@Serializable +data class BenchmarkSummary(val id: Uuid, val name: String) + +@Serializable +data class BenchmarkResponse(val id: Uuid, val name: String, val entries: List) @Serializable data class VersionResponse(val id: Uuid, val name: String, val color: String, val meta: String) @Serializable -data class EntryResponse(val id: Uuid, val labelId: Uuid, val timestamp: Instant, val warmups: List, +data class EntryResponse(val id: Uuid, val benchmarkId: Uuid, val versionId: Uuid, val timestamp: Instant, val warmups: List, val measurements: List, val unit: TimeUnit, val input: String, val hardware: String) @Serializable diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Routes.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Routes.kt index c94e8b9..4953632 100644 --- a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Routes.kt +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/Routes.kt @@ -22,6 +22,7 @@ object Routes { val handshake = ApiRoute.get("/", Elevation.UN_AUTH) val home = ApiRoute.get("/home", Elevation.AUTH) + val migrate0To1 = ApiRoute.post("/migrate/0-1", Elevation.AUTH) object Project { val new = ApiRoute.post("/project", Elevation.AUTH) @@ -30,6 +31,12 @@ object Routes { val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH) } + object Benchmark { + val new = ApiRoute.post("/benchmark", Elevation.AUTH) + val update = ApiRoute.patchUuidNoRes("/benchmark", Elevation.AUTH) + val delete = ApiRoute.deleteUuidNoRes("/benchmark", Elevation.AUTH) + } + object Version { val new = ApiRoute.post("/version", Elevation.AUTH) val update = ApiRoute.patchUuidNoRes("/version", Elevation.AUTH) diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 96e1264..9fd48ed 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -7,7 +7,10 @@ plugins { } group = "com.jaytux.phoebench" -version = rootProject.version +version = rootProject.version as PhoebenchVersion + +if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion)) + throw GradleException("Server version must be at least as high as protocol/common version") repositories { mavenCentral() diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/Main.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/Main.kt index e7bc679..04ea570 100644 --- a/server/src/main/kotlin/com/jaytux/phoebench/server/Main.kt +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/Main.kt @@ -2,6 +2,8 @@ package com.jaytux.phoebench.server import com.jaytux.phoebench.common.* import com.jaytux.phoebench.server.db.DB +import com.jaytux.phoebench.server.db.Meta +import com.jaytux.phoebench.server.db.Metas import com.jaytux.phoebench.server.db.User import com.jaytux.phoebench.server.handlers.* import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject @@ -27,6 +29,10 @@ fun Application.module() { DB.db println("Entering module for PhoeBench Server version ${ServerVersion.VERSION}...") + if(Meta.single.revision < Metas.CURRENT_REVISION) { + DB.migrate(Meta.single.revision, Metas.CURRENT_REVISION) + } + install(ContentNegotiation) { json() } @@ -120,12 +126,17 @@ fun Application.module() { deleteAdmin(Routes.Auth.User.delete, AuthHandler::deleteUser) getAuth(Routes.home, ProjectHandler::home) + postAuth(Routes.migrate0To1, ProjectHandler::migrate01) postAuth(Routes.Project.new, ProjectHandler::createProject) getAuth(Routes.Project.get, ProjectHandler::getProject) patchAuth(Routes.Project.update, ProjectHandler::updateProject) deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject) + postAuth(Routes.Benchmark.new, ProjectHandler::createBenchmark) + patchAuth(Routes.Benchmark.update, ProjectHandler::updateBenchmark) + deleteAuth(Routes.Benchmark.delete, ProjectHandler::deleteBenchmark) + postAuth(Routes.Version.new, ProjectHandler::createVersion) patchAuth(Routes.Version.update, ProjectHandler::updateVersion) deleteAuth(Routes.Version.delete, ProjectHandler::deleteVersion) diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/Util.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/Util.kt index 294cd71..2c37b18 100644 --- a/server/src/main/kotlin/com/jaytux/phoebench/server/Util.kt +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/Util.kt @@ -21,4 +21,6 @@ fun nowPlusDays(days: Int): Instant = nowPlus(days, DateTimeUnit.DAY) fun nowPlus(time: Int, unit: DateTimeUnit): Instant { val now = Clock.System.now() return now.plus(time, unit, systemTZ) -} \ No newline at end of file +} + +infix fun Pair.app(t3: T3) = Triple(first, second, t3) \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/db/DB.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/db/DB.kt index 50639ee..58fd174 100644 --- a/server/src/main/kotlin/com/jaytux/phoebench/server/db/DB.kt +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/db/DB.kt @@ -1,9 +1,11 @@ package com.jaytux.phoebench.server.db import com.jaytux.phoebench.server.DotEnv +import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils object DB { @@ -17,7 +19,7 @@ object DB { transaction { SchemaUtils.create(Users, Invites, RefreshTokens, Projects, Versions, Entries) - val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Versions, Entries) + val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Versions, Entries, Metas, EntriesV2, Benchmarks) try { migration.forEach { exec(it) @@ -29,4 +31,29 @@ object DB { } } } + + fun migrate(from: Int, to: Int) = when(from) { + 0 -> if(to == 1) migrate0To1() else null + else -> null + } ?: throw IllegalArgumentException("Migration $from -> $to is not defined") + + private fun migrate0To1() { + transaction { + Users.update(where = { Users.isAdmin eq true }) { it[benchmarkLimit] = -1 } + Users.update(where = { Users.isAdmin eq false }) { it.update(benchmarkLimit, projectLimit) } + } + + transaction { + User.all().forEach { + if(it.projects.count() == 0L) { + it.projectVersion = Projects.CURRENT_VERSION + return@forEach + } + } + } + + transaction { + Meta.single.revision = 1 + } + } } \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/db/Entities.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/db/Entities.kt index e99b2e4..e96c9ad 100644 --- a/server/src/main/kotlin/com/jaytux/phoebench/server/db/Entities.kt +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/db/Entities.kt @@ -1,10 +1,25 @@ package com.jaytux.phoebench.server.db +import org.jetbrains.exposed.v1.core.Transaction import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.dao.Entity import org.jetbrains.exposed.v1.dao.EntityClass +import org.jetbrains.exposed.v1.jdbc.transactions.transaction import kotlin.uuid.Uuid +class Meta(id: EntityID) : Entity(id) { + companion object : EntityClass(Metas) { + val single by lazy { + transaction { + val got = all().limit(1).toList() + got.firstOrNull() ?: new {} + } + } + } + + var revision by Metas.revision +} + class User(id: EntityID) : Entity(id) { companion object : EntityClass(Users) @@ -13,6 +28,8 @@ class User(id: EntityID) : Entity(id) { var isAdmin by Users.isAdmin var isOwner by Users.isOwner var projectLimit by Users.projectLimit + var benchmarkLimit by Users.benchmarkLimit + var projectVersion by Users.projectsVersion val projects by Project referrersOn Projects.ownerId val sessions by RefreshToken referrersOn RefreshTokens.userId @@ -42,8 +59,18 @@ class Project(id: EntityID) : Entity(id) { var isPublic by Projects.isPublic var owner by User referencedOn Projects.ownerId - val labels by Version referrersOn Versions.projectId - val entries by Entry referrersOn Entries.projectId + val versions by Version referrersOn Versions.projectId + val benchmarks by Benchmark referrersOn Benchmarks.projectId +} + +class Benchmark(id: EntityID) : Entity(id) { + companion object : EntityClass(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) : Entity(id) { @@ -71,4 +98,20 @@ class Entry(id: EntityID) : Entity(id) { var project by Project referencedOn Entries.projectId var version by Version referencedOn Entries.versionId +} + +class EntryV2(id: EntityID) : Entity(id) { + companion object : EntityClass(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 } \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/db/Tables.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/db/Tables.kt index d4b9366..363ce21 100644 --- a/server/src/main/kotlin/com/jaytux/phoebench/server/db/Tables.kt +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/db/Tables.kt @@ -2,16 +2,26 @@ package com.jaytux.phoebench.server.db import com.jaytux.phoebench.common.TimeUnit import org.jetbrains.exposed.v1.core.ReferenceOption +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable import org.jetbrains.exposed.v1.core.dao.id.UuidTable import org.jetbrains.exposed.v1.datetime.timestamp import kotlin.time.Clock +object Metas : IntIdTable() { + const val CURRENT_REVISION = 1 + + val revision = integer("revision").default(0) +} + object Users : UuidTable() { val username = varchar("username", 255).uniqueIndex() val password = varchar("password", 255) val isAdmin = bool("is_admin").default(false) val isOwner = bool("is_owner").default(false) val projectLimit = integer("project_limit").default(1) + val benchmarkLimit = integer("benchmark_limit").default(1) + + val projectsVersion = integer("project_version").default(0) } object Invites : UuidTable() { @@ -25,6 +35,8 @@ object RefreshTokens : UuidTable() { } object Projects : UuidTable() { + const val CURRENT_VERSION = 1 + val ownerId = reference("owner_id", Users, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) val name = varchar("name", 255) val isPublic = bool("is_public").default(false) @@ -34,6 +46,15 @@ object Projects : UuidTable() { } } +object Benchmarks : UuidTable() { + val projectId = reference("project_id", Projects, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) + val name = varchar("name", 255) + + init { + uniqueIndex(projectId, name) + } +} + object Versions : UuidTable() { val label = varchar("label", 255) val color = varchar("color", 7) @@ -50,4 +71,15 @@ object Entries : UuidTable() { val unit = enumeration("unit") val input = varchar("input", 255) val hardware = varchar("hardware", 255) +} + +object EntriesV2 : UuidTable() { + val benchmarkId = reference("benchmark_id", Benchmarks, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) + val versionId = reference("version_id", Versions, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) + val timestamp = timestamp("timestamp").default(Clock.System.now()) + val warmups = list("warmups") + val measurements = list("measurements") + val unit = enumeration("unit") + val input = varchar("input", 255) + val hardware = varchar("hardware", 255) } \ No newline at end of file diff --git a/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/ProjectHandler.kt b/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/ProjectHandler.kt index 191d0d3..3bdfbbc 100644 --- a/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/ProjectHandler.kt +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/handlers/ProjectHandler.kt @@ -1,22 +1,31 @@ package com.jaytux.phoebench.server.handlers +import com.jaytux.phoebench.common.BenchmarkRequest +import com.jaytux.phoebench.common.BenchmarkResponse +import com.jaytux.phoebench.common.BenchmarkSummary import com.jaytux.phoebench.common.EmptyRequest import com.jaytux.phoebench.common.EmptyResponse import com.jaytux.phoebench.common.EntryRequest import com.jaytux.phoebench.common.EntryResponse import com.jaytux.phoebench.common.HomeEvent import com.jaytux.phoebench.common.HomeResponse +import com.jaytux.phoebench.common.Migrate0To1Request import com.jaytux.phoebench.common.VersionRequest import com.jaytux.phoebench.common.VersionResponse import com.jaytux.phoebench.common.NamedID +import com.jaytux.phoebench.common.PartialBenchmarkRequest import com.jaytux.phoebench.common.PartialProjectRequest import com.jaytux.phoebench.common.PartialVersionRequest import com.jaytux.phoebench.common.ProjectEvent import com.jaytux.phoebench.common.ProjectRequest import com.jaytux.phoebench.common.ProjectResponse import com.jaytux.phoebench.server.Buses +import com.jaytux.phoebench.server.app +import com.jaytux.phoebench.server.db.Benchmark +import com.jaytux.phoebench.server.db.Benchmarks import com.jaytux.phoebench.server.db.Entries import com.jaytux.phoebench.server.db.Entry +import com.jaytux.phoebench.server.db.EntryV2 import com.jaytux.phoebench.server.db.Version import com.jaytux.phoebench.server.db.Versions import com.jaytux.phoebench.server.db.Project @@ -27,7 +36,12 @@ import io.ktor.http.HttpStatusCode import kotlinx.coroutines.launch import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.Transaction +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.notInList +import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.transactions.transaction import kotlin.uuid.Uuid @@ -40,23 +54,44 @@ object ProjectHandler { return when { isEditableBy(user) -> this isPublic && !forEditing -> this - else -> throw RouteError("Invalid project ID.", HttpStatusCode.NotFound) + else -> throw RouteError("Invalid ID.", HttpStatusCode.NotFound) } } + context(trns: Transaction) + fun Benchmark.isAccessible(user: User, forEditing: Boolean): Pair { + return project.isAccessible(user, forEditing) to this + } + fun Transaction.accessibleProject(user: User, id: Uuid, forEditing: Boolean): Project { val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound) return project.isAccessible(user, forEditing) } + fun Transaction.accessibleBenchmark(user: User, id: Uuid, forEditing: Boolean): Pair { + val bench = Benchmark.findById(id) ?: throw RouteError("Invalid benchmark ID.", HttpStatusCode.NotFound) + return bench.isAccessible(user, forEditing) + } + + fun Transaction.checkMigration(user: User) { + if(user.projectVersion < Projects.CURRENT_VERSION) + throw RouteError("You need to migrate your projects first.", HttpStatusCode.Conflict) + } + context(trns: Transaction) private fun Project.toResponse(user: User) = ProjectResponse( id.value, name, NamedID(owner.username, owner.id.value), isPublic, isEditableBy(user), - labels.orderBy(Versions.label to SortOrder.ASC).map { VersionResponse(it.id.value, it.label, it.color, it.meta) }, - entries.orderBy(Entries.timestamp to SortOrder.ASC).map { EntryResponse(it.id.value, it.version.id.value, it.timestamp, it.warmups, it.measurements, it.unit, it.input, it.hardware) }) + benchmarks.orderBy(Benchmarks.name to SortOrder.ASC).map { b -> BenchmarkResponse( + b.id.value, b.name, b.entries.map { e -> + EntryResponse(e.id.value, e.benchmarkId.value, e.versionId.value, e.timestamp, e.warmups, e.measurements, e.unit, e.input, e.hardware) + } + ) }, + versions.orderBy(Versions.label to SortOrder.ASC).map { VersionResponse(it.id.value, it.label, it.color, it.meta) } + ) fun home(user: User, req: EmptyRequest) = transaction { - val own = user.projects.orderBy(Projects.name to SortOrder.ASC).map { + val own = user.projects.orderBy(Projects.name to SortOrder.ASC).mapNotNull { + if(it.name.startsWith("() + var version = user.projectVersion + while(version < Projects.CURRENT_VERSION) { + migrations += when(version) { + 0 -> HomeResponse.NeededMigration.Migrate0To1(user.projects.map { p -> + HomeResponse.NeededMigration.Migrate0To1.OldProject(p.id.value, p.name, p.versions.map { VersionResponse(it.id.value, it.label, it.color, it.meta) }) + }) + else -> throw IllegalArgumentException("No migration starting from $version possible.") + } + ++version + } + + success(HomeResponse(user.username, user.isAdmin, user.projectLimit, own, publics, migrations)) } suspend fun createProject(user: User, req: ProjectRequest) = transaction { + checkMigration(user) if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit)) throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict) @@ -88,11 +136,13 @@ object ProjectHandler { } fun getProject(user: User, req: Uuid) = transaction { + checkMigration(user) val proj = accessibleProject(user, req, false) success(proj.toResponse(user)) } fun updateProject(user: User, req: Pair) = transaction { + checkMigration(user) val proj = accessibleProject(user, req.first, true) val changes = req.second changes.name?.let { proj.name = it } @@ -113,6 +163,7 @@ object ProjectHandler { } fun deleteProject(user: User, req: Uuid) = transaction { + checkMigration(user) val proj = accessibleProject(user, req, true) proj.delete() ServerScope.launch { @@ -124,8 +175,57 @@ object ProjectHandler { success(EmptyResponse()) } - fun createVersion(user: User, req: VersionRequest) = transaction { + fun createBenchmark(user: User, req: BenchmarkRequest) = transaction { + checkMigration(user) val proj = accessibleProject(user, req.projectId, true) + val bench = Benchmark.new { + name = req.name + projectId = proj.id + } + + val res = BenchmarkResponse(bench.id.value, bench.name, bench.entries.map { + EntryResponse(it.id.value, it.benchmarkId.value, it.versionId.value, it.timestamp, it.warmups, it.measurements, it.unit, it.input, it.hardware) + }) + + ServerScope.launch { + val projectBus = Buses.projectBus(proj.id.value) + if(proj.isPublic) projectBus.sendAll(ProjectEvent.NewBenchmark(res)) + else projectBus.send(user.id.value, ProjectEvent.NewBenchmark(res)) + } + + success(res) + } + + fun updateBenchmark(user: User, req: Pair) = transaction { + checkMigration(user) + val (proj, bench) = accessibleBenchmark(user, req.first, true) + val changes = req.second + changes.name?.let { proj.name = it } + + ServerScope.launch { + Buses.projectBus(proj.id.value).sendAll(ProjectEvent.BenchmarkChanged( + BenchmarkSummary(bench.id.value, bench.name) + )) + } + + success(EmptyResponse()) + } + + fun deleteBenchmark(user: User, req: Uuid) = transaction { + checkMigration(user) + val (proj, bench) = accessibleBenchmark(user, req, true) + bench.delete() + + ServerScope.launch { + Buses.projectBus(proj.id.value).sendAll(ProjectEvent.BenchmarkDeleted(req)) + } + + success(EmptyResponse()) + } + + fun createVersion(user: User, req: VersionRequest) = transaction { + checkMigration(user) + val proj = accessibleProject(user, req.benchmarkId, true) if(req.color.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest) val ver = Version.new { label = req.name @@ -136,13 +236,14 @@ object ProjectHandler { val res = VersionResponse(ver.id.value, ver.label, ver.color, ver.meta) ServerScope.launch { - Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewVersion(res)) + Buses.projectBus(req.benchmarkId).sendAll(ProjectEvent.NewVersion(res)) } success(res) } fun updateVersion(user: User, req: Pair) = transaction { + checkMigration(user) val ver = Version.findById(req.first) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound) ver.project.isAccessible(user, true) val changes = req.second @@ -163,6 +264,7 @@ object ProjectHandler { } fun deleteVersion(user: User, req: Uuid) = transaction { + checkMigration(user) val ver = Version.findById(req) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound) ver.project.isAccessible(user, true) ver.delete() @@ -175,14 +277,15 @@ object ProjectHandler { } fun createEntry(user: User, req: EntryRequest) = transaction { - val proj = accessibleProject(user, req.projectId, true) - val entry = Entry.new { + checkMigration(user) + val (proj, bench) = accessibleBenchmark(user, req.benchmarkId, true) + val entry = EntryV2.new { version = when(val l = Version.findById(req.version)) { null -> throw RouteError("Invalid version ID.", HttpStatusCode.NotFound) is Version if l.projectId.value != proj.id.value -> throw RouteError("Version is attached to a different project.", HttpStatusCode.Conflict) else -> l } - project = proj + benchmarkId = bench.id measurements = req.measurements timestamp = req.timestamp warmups = req.warmups @@ -191,7 +294,8 @@ object ProjectHandler { hardware = req.hardware } - val response = EntryResponse(entry.id.value, entry.version.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit, entry.input, entry.hardware) + val response = EntryResponse(entry.id.value, entry.benchmarkId.value, + entry.version.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit, entry.input, entry.hardware) ServerScope.launch { Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response)) } @@ -199,12 +303,82 @@ object ProjectHandler { } fun deleteEntry(user: User, req: Uuid) = transaction { - val entry = Entry.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound) - entry.project.isAccessible(user, true) + checkMigration(user) + val entry = EntryV2.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound) + val (proj, bench) = entry.benchmark.isAccessible(user, true) entry.delete() ServerScope.launch { - Buses.projectBus(entry.project.id.value).sendAll(ProjectEvent.EntryDeleted(req)) + Buses.projectBus(proj.id.value).sendAll(ProjectEvent.EntryDeleted(req, bench.id.value)) } success(EmptyResponse()) } + + fun migrate01(user: User, req: Migrate0To1Request): Pair { + 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>() + val duplicateMap = mutableMapOf>() + 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()) + } } \ No newline at end of file