Database reorganize, protocol version bump, migrations

This commit is contained in:
2026-08-26 23:13:47 +02:00
parent 7343300536
commit 4fdeafb73b
31 changed files with 1058 additions and 114 deletions
+5 -2
View File
@@ -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<Task>("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
@@ -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)
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?
@@ -85,8 +85,21 @@ inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T
return toMutableList().apply { add(index, elem) }
}
fun List<Float>.geomean(): Float {
return (size.toFloat() / sumOf { 1.0 / it.toDouble() }).toFloat()
inline fun <T, X : Comparable<X>> List<T>.insortIdx(elem: T, crossinline sortBy: (T) -> X): Pair<List<T>, Int?> {
val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy)
if(insertionPoint >= 0) return this to null
val index = -insertionPoint - 1
return toMutableList().apply { add(index, elem) } to index
}
inline fun <T> List<T>.replace(crossinline finder: (T) -> Boolean, crossinline update: (T) -> T): List<T> {
val idx = indexOfFirst { finder(it) }
if(idx == -1) return this
val res = ArrayList(this)
res[idx] = update(res[idx])
return res
}
infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
@@ -122,4 +135,6 @@ fun <T1, T2, T3, T4> List<Tuple4<T1, T2, T3, T4>>.unzip(): Tuple4<List<T1>, List
}
return l1 to l2 app l3 app l4
}
}
fun <T> List<T>.removeAt(idx: Int): List<T> = subList(0, idx) + subList(idx + 1, size)
@@ -17,6 +17,7 @@ import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.InviteListResponse
import com.jaytux.phoebench.common.Migrate0To1Request
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.UserListResponse
import com.jaytux.phoebench.common.value
@@ -40,11 +41,14 @@ class HomeVM(
private val _users = mutableStateOf(listOf<UserListResponse.UserData>())
private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>())
private val _stepsNeeded = mutableStateOf(listOf<HomeResponse.NeededMigration>())
val username = _username.immutable()
val isAdmin = _isAdmin.immutable()
val projectLimit = _projectLimit.immutable()
val ownProjects = _ownProjects.immutable()
val publicProjects = _publicProjects.immutable()
val stepsNeeded = _stepsNeeded.immutable()
val invites = _invites.immutable()
val users = _users.immutable()
@@ -81,17 +85,22 @@ class HomeVM(
withScope {
resetAdmin()
_repo.getHome().snackOr {
println("Received home response $it")
_username.value = it.username
_isAdmin.value = it.isAdmin
_projectLimit.value = it.projectLimit
_ownProjects.value = it.ownProjects.sortedBy { p -> p.name }
_publicProjects.value = it.publicProjects.sortedBy { p -> p.name }
refreshAdmin()
refreshFrom(it)
}
}
}
private suspend fun refreshFrom(it: HomeResponse) {
println("Received home response $it")
_username.value = it.username
_isAdmin.value = it.isAdmin
_projectLimit.value = it.projectLimit
_ownProjects.value = it.ownProjects.sortedBy { p -> p.name }
_publicProjects.value = it.publicProjects.sortedBy { p -> p.name }
_stepsNeeded.value = it.neededMigrations
refreshAdmin()
}
fun refreshAdmin() {
if(_isAdmin.value) {
_adminJob = withScope {
@@ -212,4 +221,12 @@ class HomeVM(
}
}
}
fun migrate(v0to1: List<Migrate0To1Request.MigrateProject>) {
withScope {
_repo.migrate0To1(v0to1).snackOr {
refreshFrom(it)
}
}
}
}
@@ -9,6 +9,7 @@ import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.InviteListResponse
import com.jaytux.phoebench.common.InviteRequest
import com.jaytux.phoebench.common.LogoutRequest
import com.jaytux.phoebench.common.Migrate0To1Request
import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.UserListResponse
@@ -31,6 +32,8 @@ interface IHomeRepo {
suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit>
suspend fun migrate0To1(conversions: List<Migrate0To1Request.MigrateProject>): Either<ErrorResponse, HomeResponse>
companion object {
class Default(private val _client: Client) : IHomeRepo {
override suspend fun getHome(): Either<ErrorResponse, HomeResponse> =
@@ -62,6 +65,9 @@ interface IHomeRepo {
override suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.new, ProjectRequest(name, isPublic)).ignoreValue()
override suspend fun migrate0To1(conversions: List<Migrate0To1Request.MigrateProject>): Either<ErrorResponse, HomeResponse> =
_client.callRoute(Routes.migrate0To1, Migrate0To1Request(conversions))
}
fun default(client: Client) = Default(client)
@@ -4,10 +4,13 @@ import androidx.compose.ui.graphics.Color
import com.jaytux.phoebench.clients.Client
import com.jaytux.phoebench.clients.hexString
import com.jaytux.phoebench.clients.ignoreValue
import com.jaytux.phoebench.common.BenchmarkRequest
import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.EntryRequest
import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.PartialBenchmarkRequest
import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.PartialProjectRequest
@@ -23,11 +26,15 @@ interface IProjectRepo {
suspend fun update(name: String? = null, isPublic: Boolean? = null): Either<ErrorResponse, Unit>
suspend fun delete(): Either<ErrorResponse, Unit>
suspend fun newBenchmark(name: String): Either<ErrorResponse, BenchmarkResponse>
suspend fun updateBenchmark(id: Uuid, name: String? = null): Either<ErrorResponse, Unit>
suspend fun deleteBenchmark(id: Uuid): Either<ErrorResponse, Unit>
suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse>
suspend fun updateVersion(id: Uuid, name: String? = null, color: Color? = null, meta: String? = null): Either<ErrorResponse, Unit>
suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit>
suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
suspend fun newEntry(benchmark: Uuid, label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
unit: TimeUnit, input: String, hardware: String): Either<ErrorResponse, EntryResponse>
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
@@ -42,6 +49,15 @@ interface IProjectRepo {
override suspend fun delete(): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.delete, _projectId).ignoreValue()
override suspend fun newBenchmark(name: String): Either<ErrorResponse, BenchmarkResponse> =
_client.callRoute(Routes.Benchmark.new, BenchmarkRequest(name, _projectId))
override suspend fun updateBenchmark(id: Uuid, name: String?): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Benchmark.update, id to PartialBenchmarkRequest(name = name)).ignoreValue()
override suspend fun deleteBenchmark(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Benchmark.delete, id).ignoreValue()
override suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse> =
_client.callRoute(Routes.Version.new, VersionRequest(name, color.hexString(), meta, _projectId))
@@ -51,10 +67,10 @@ interface IProjectRepo {
override suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Version.delete, id).ignoreValue()
override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>,
override suspend fun newEntry(benchmark: Uuid, label: Uuid, timestamp: Instant, warmups: List<Float>,
measurements: List<Float>, unit: TimeUnit, input: String, hardware: String
): Either<ErrorResponse, EntryResponse> =
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit, input, hardware))
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, benchmark, warmups, measurements, unit, input, hardware))
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Entry.delete, id).ignoreValue()
@@ -11,7 +11,10 @@ import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
import com.jaytux.phoebench.clients.hexString
import com.jaytux.phoebench.clients.immutable
import com.jaytux.phoebench.clients.insort
import com.jaytux.phoebench.clients.insortIdx
import com.jaytux.phoebench.clients.replace
import com.jaytux.phoebench.clients.withScope
import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.ProjectEvent
@@ -57,9 +60,20 @@ class ProjectVM(
data class Entry(val id: Uuid, val label: Version, val timeStamp: Instant, val warmups: List<Float>,
val measurements: List<Float>, val nativeUnit: TimeUnit, val input: String, val hardware: String) {
companion object {
fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>) = Entry(
it.id, map[it.labelId] ?: Version.invalid, it.timestamp,
inline fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>, addInput: (String) -> Unit, addHardware: (String) -> Unit) = Entry(
it.id, map[it.versionId] ?: Version.invalid, it.timestamp,
it.warmups, it.measurements, it.unit, it.input, it.hardware
).also { addInput(it.input); addHardware(it.hardware) }
}
}
data class Benchmark(val id: Uuid, val name: String, val entries: List<Entry>) {
fun add(entry: Entry) = copy(entries = entries.insort(entry) { it.timeStamp })
fun remove(entry: Uuid) = copy(entries = entries.filter { it.id != entry })
companion object {
inline fun fromResponse(it: BenchmarkResponse, map: Map<Uuid, Version>, addInput: (String) -> Unit, addHardware: (String) -> Unit) = Benchmark(
it.id, it.name, it.entries.map { er -> Entry.fromResponse(er, map, addInput, addHardware) }
)
}
}
@@ -69,7 +83,8 @@ class ProjectVM(
private val _public = mutableStateOf(false)
private val _editable = mutableStateOf(false)
private val _labels = mutableStateOf(mapOf<Uuid, Version>())
private val _entries = mutableStateOf(listOf<Entry>())
private val _benchmarks = mutableStateOf(listOf<Benchmark>())
private val _currentBenchmark = mutableStateOf(0)
private val _inputs = mutableStateOf(setOf<String>())
private val _hardware = mutableStateOf(setOf<String>())
@@ -78,7 +93,8 @@ class ProjectVM(
val public = _public.immutable()
val editable = _editable.immutable()
val versions = _labels.immutable()
val entries = _entries.immutable()
val benchmarks = _benchmarks.immutable()
val currentBenchmark = _currentBenchmark.immutable()
val inputs = _inputs.immutable()
val hardware = _hardware.immutable()
@@ -102,14 +118,10 @@ class ProjectVM(
_owner.value = it.owner.name
_public.value = it.isPublic
_editable.value = it.isEditable
_labels.value = it.usedLabels.associate { l -> l.id to Version.fromResponse(l) }
_labels.value = it.versions.associate { l -> l.id to Version.fromResponse(l) }
val localInputs = mutableSetOf<String>()
val localHardware = mutableSetOf<String>()
_entries.value = it.entries.map { e ->
localInputs += e.input
localHardware += e.hardware
Entry.fromResponse(e, _labels.value)
}
_benchmarks.value = it.benchmarks.map { b -> Benchmark.fromResponse(b, _labels.value, localInputs::add, localHardware::add) }
_inputs.value = localInputs
_hardware.value = localHardware
}
@@ -119,13 +131,12 @@ class ProjectVM(
private fun handleProjectEvent(event: ProjectEvent) {
when(event) {
ProjectEvent.Deleted -> back()
is ProjectEvent.EntryDeleted -> _entries.value = _entries.value.filter { it.id != event.id }
is ProjectEvent.EntryDeleted -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmarkId }) { it.remove(event.id) }
is ProjectEvent.VersionChanged -> _labels.value += (event.label.id to Version.fromResponse(event.label))
is ProjectEvent.VersionDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id }
is ProjectEvent.NewEntry -> {
_entries.value = _entries.value.insort(Entry.fromResponse(event.entry, _labels.value), Entry::timeStamp)
_inputs.value += event.entry.input
_hardware.value += event.entry.hardware
_benchmarks.value = _benchmarks.value.replace({ it.id == event.entry.benchmarkId }) { it.add(Entry.fromResponse(event.entry, _labels.value,
{ input -> _inputs.value += input }, { hw -> _hardware.value += hw })) }
}
is ProjectEvent.NewVersion -> _labels.value += (event.label.id to Version.fromResponse(event.label))
is ProjectEvent.Updated -> {
@@ -133,6 +144,23 @@ class ProjectVM(
_owner.value = event.changes.owner.name
_public.value = event.changes.isPublic
}
is ProjectEvent.BenchmarkChanged -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmark.id }) { it.copy(name = event.benchmark.name) }
is ProjectEvent.BenchmarkDeleted -> {
val idx = _benchmarks.value.indexOfFirst { it.id == event.id }
if(idx == _currentBenchmark.value) _currentBenchmark.value = 0
else if(idx < _currentBenchmark.value) _currentBenchmark.value -= 1
_benchmarks.value = _benchmarks.value.filter { it.id != event.id }
}
is ProjectEvent.NewBenchmark -> {
val (updated, idx) = _benchmarks.value.insortIdx(
Benchmark.fromResponse(event.benchmark, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw })
) { it.name }
if(idx != null) {
if(idx < _currentBenchmark.value) _currentBenchmark.value += 1
}
_benchmarks.value = updated
}
}
}
@@ -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 {
_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<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
fun HomeView(onSelectProject: (Uuid) -> Unit) {
val vm = viewModel { HomeVM() }
val todo by vm.stepsNeeded
todo.firstOrNull()?.let {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (it) {
is HomeResponse.NeededMigration.Migrate0To1 -> Migrate0To1View(vm, it)
}
}
} ?: HomePanel(vm, onSelectProject)
}
@Composable
fun HomePanel(vm: HomeVM, onSelectProject: (Uuid) -> Unit) {
val auth = AuthProvider.get()
val username by vm.username
val isAdmin by vm.isAdmin
@@ -0,0 +1,241 @@
package com.jaytux.phoebench.clients.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.draganddrop.dragAndDropSource
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.composables.icons.lucide.Cross
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.X
import com.jaytux.phoebench.clients.app
import com.jaytux.phoebench.clients.data.HomeVM
import com.jaytux.phoebench.clients.drag
import com.jaytux.phoebench.clients.drop
import com.jaytux.phoebench.clients.removeAt
import com.jaytux.phoebench.clients.replace
import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.Migrate0To1Request
@Composable
fun Migrate0To1View(vm: HomeVM, input: HomeResponse.NeededMigration.Migrate0To1) {
var isAdding by remember { mutableStateOf(false) }
var projectsToDo by remember { mutableStateOf(input.projects) }
var remapped by remember { mutableStateOf(listOf<Triple<String, Boolean, List<HomeResponse.NeededMigration.Migrate0To1.OldProject>>>()) }
Surface(Modifier.fillMaxSize(0.85f), color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 10.dp, shape = MaterialTheme.shapes.large) {
Column(Modifier.padding(15.dp)) {
Text("Data Migration", style = MaterialTheme.typography.headlineMedium)
Text("The server you connected to has been updated, requiring some decisions from you.")
Text("This new version allows you to organise different benchmarks together in one project. That way, they will share installed versions.")
Text("Please create the new projects you would like to use, and drag the existing projects onto them as benchmarks. You can also drag a benchmark onto an empty space to have it become a top-level project containing a benchmark with the same name.")
Spacer(Modifier.height(10.dp))
Row(Modifier.weight(1f)) {
var showTargets by remember { mutableStateOf(false) }
val globalTarget = remember {
object : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) {
showTargets = true
super.onStarted(event)
}
override fun onEnded(event: DragAndDropEvent) {
showTargets = false
super.onEnded(event)
}
override fun onDrop(event: DragAndDropEvent): Boolean {
val got = drop<HomeResponse.NeededMigration.Migrate0To1.OldProject>(event) ?: return false
println("DROP ONTO global")
projectsToDo = projectsToDo.filter { it.id != got.id }
remapped += got.name to false app listOf(got)
return true
}
}
}
val startMod = if(showTargets) Modifier.border(BorderStroke(3.dp, MaterialTheme.colorScheme.onSurface)) else Modifier
Surface(startMod.weight(0.66f).fillMaxHeight().dragAndDropTarget({ true }, globalTarget), tonalElevation = 10.dp, shape = MaterialTheme.shapes.large) {
Column(Modifier.padding(10.dp)) {
Text("New projects", style = MaterialTheme.typography.headlineSmall)
LazyVerticalGrid(GridCells.Adaptive(minSize = 350.dp), Modifier.weight(1f)) {
itemsIndexed(remapped) { idx, (name, public, old) ->
val target = remember {
object : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) {
showTargets = true
super.onStarted(event)
}
override fun onEnded(event: DragAndDropEvent) {
showTargets = false
super.onEnded(event)
}
override fun onDrop(event: DragAndDropEvent): Boolean {
val got = drop<HomeResponse.NeededMigration.Migrate0To1.OldProject>(event) ?: return false
println("DROP ONTO $name")
projectsToDo = projectsToDo.filter { it.id != got.id }
remapped = remapped.replace({ it.first == name }) { it.first to it.second app (it.third + got) }
return true
}
}
}
val toggle = { public: Boolean ->
remapped = remapped.replace({ it.first == name }) { it.first to public app it.third }
}
Surface(startMod.padding(5.dp).dragAndDropTarget({ true }, target), color = MaterialTheme.colorScheme.tertiaryContainer, tonalElevation = 4.dp, shadowElevation = 1.dp, shape = MaterialTheme.shapes.medium) {
Row(Modifier.padding(5.dp).width(IntrinsicSize.Min)) {
Column(Modifier.weight(1f)) {
Text(name)
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(!public, { toggle(false) }, SegmentedButtonDefaults.itemShape(0, 2)) { Text("Private") }
SegmentedButton(public, { toggle(true) }, SegmentedButtonDefaults.itemShape(1, 2)) { Text("Public") }
}
Column(Modifier.padding(start = 5.dp)) {
old.forEach { Text(it.name, style = MaterialTheme.typography.bodySmall) }
}
}
IconButton({ projectsToDo += old; remapped = remapped.removeAt(idx) }) {
Icon(Lucide.X, "Remove")
}
}
}
}
if(remapped.isNotEmpty()) {
item {
Surface(startMod.padding(5.dp).dragAndDropTarget({ true }, globalTarget), color = MaterialTheme.colorScheme.tertiaryContainer, tonalElevation = 4.dp, shadowElevation = 1.dp, shape = MaterialTheme.shapes.medium) {
Text("Create new...", Modifier.padding(vertical = 15.dp), fontStyle = FontStyle.Italic, color = LocalContentColor.current.copy(alpha = 0.66f))
}
}
}
}
Spacer(Modifier.height(10.dp))
Row {
Box(Modifier.weight(1f))
IconButton({ isAdding = true }) {
Icon(Lucide.Plus, "Add project")
}
}
}
}
Spacer(Modifier.width(10.dp))
Surface(Modifier.weight(0.33f).fillMaxHeight(), tonalElevation = 10.dp, shape = MaterialTheme.shapes.large) {
LazyColumn(Modifier.padding(10.dp).fillMaxHeight()) {
stickyHeader {
Text("Existing projects", style = MaterialTheme.typography.headlineSmall)
}
items(projectsToDo) {
Column {
Surface(Modifier.padding(5.dp).fillMaxWidth().dragAndDropSource { _ -> drag(it) }, tonalElevation = 15.dp, shape = MaterialTheme.shapes.medium) {
Text("${it.name} (${it.versions.size} versions)", Modifier.padding(10.dp))
}
Spacer(Modifier.height(10.dp))
}
}
}
}
}
Spacer(Modifier.height(10.dp))
Row {
Box(Modifier.weight(1f))
Button({ vm.migrate(remapped.map { (name, public, old) -> Migrate0To1Request.MigrateProject(name, public, old.map { it.id }) }) },
enabled = projectsToDo.isEmpty()) {
Text("Perform migration")
}
}
}
}
if(isAdding) AddProjectDialog({ isAdding = false }) { name, public ->
remapped += name to public app listOf()
isAdding = false
}
}
@Composable
fun AddProjectDialog(onCancel: () -> Unit, onAdd: (name: String, public: Boolean) -> Unit) {
Dialog(onDismissRequest = onCancel) {
var name by remember { mutableStateOf("") }
var public by remember { mutableStateOf(false) }
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
Text("Create new project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
Spacer(Modifier.height(10.dp))
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(!public, { public = false }, SegmentedButtonDefaults.itemShape(0, 2)) { Text("Private") }
SegmentedButton(public, { public = true }, SegmentedButtonDefaults.itemShape(1, 2)) { Text("Public") }
}
Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, {
onAdd(name.trim(), public)
}, confirmText = "Create", canConfirm = name.trim().isNotBlank())
}
}
}
}
@@ -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<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) }
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
@@ -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)
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
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)
}
}
@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)
}