From 07225ae461c4ff24c9c3cf66fbb58ac2d2598fa6 Mon Sep 17 00:00:00 2001 From: jay-tux Date: Mon, 24 Aug 2026 17:07:19 +0200 Subject: [PATCH] UI improvements, CLI fallbacks --- build.gradle.kts | 2 +- clients/cli/build.gradle.kts | 29 +- .../com/jaytux/phoebench/clients/cli/CLI.kt | 12 + clients/compose/build.gradle.kts | 63 ++- .../jaytux/phoebench/clients/PlatformAPI.kt | 8 +- .../com/jaytux/phoebench/clients/Util.kt | 46 ++- .../phoebench/clients/data/IProjectRepo.kt | 4 +- .../phoebench/clients/data/ProjectVM.kt | 4 +- .../jaytux/phoebench/clients/ui/HomeView.kt | 25 +- .../phoebench/clients/ui/ProjectView.kt | 360 +++++++++++------- .../phoebench/clients/PlatformAPI.desktop.kt | 9 +- .../phoebench/clients/PlatformAPI.wasmJs.kt | 15 +- gradle/libs.versions.toml | 4 +- server/build.gradle.kts | 28 ++ .../com/jaytux/phoebench/server/Main.kt | 24 +- .../main/resources/simplelogger.properties | 1 + 16 files changed, 464 insertions(+), 170 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 8698354..8e87ec6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,4 +12,4 @@ repositories { mavenCentral() } -version = "1.0.0-dev" \ No newline at end of file +version = "1.0.0" \ No newline at end of file diff --git a/clients/cli/build.gradle.kts b/clients/cli/build.gradle.kts index ca81f90..effcbd3 100644 --- a/clients/cli/build.gradle.kts +++ b/clients/cli/build.gradle.kts @@ -8,7 +8,22 @@ plugins { } group = "com.jaytux.phoebench" -version = rootProject.version.toString() +version = "1.0.1" + +val versionDirectory = layout.buildDirectory.dir("generated/sources/version") +val generateVersion = tasks.register("serverVersion") { + doFirst { + val outFile = versionDirectory.get().file("com/jaytux/phoebench/client/cli/Version.kt").asFile + outFile.parentFile.mkdirs() + outFile.writeText(""" + package com.jaytux.phoebench.client.cli + + object CliVersion { + const val VERSION="${rootProject.version}" + } + """.trimIndent()) + } +} dependencies { implementation(kotlin("stdlib")) @@ -42,6 +57,18 @@ kotlin { freeCompilerArgs.add("-Xcontext-parameters") optIn.add("kotlin.uuid.ExperimentalUuidApi") } + + sourceSets { + val main by getting { + kotlin { + srcDir(versionDirectory) + } + } + } + + tasks.compileKotlin { + dependsOn(generateVersion) + } } tasks.withType { 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 50f765b..b1bc236 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 @@ -6,6 +6,7 @@ import com.github.ajalt.clikt.parameters.groups.single import com.github.ajalt.clikt.parameters.options.* import com.github.ajalt.clikt.parameters.types.float import com.github.ajalt.clikt.parameters.types.inputStream +import com.jaytux.phoebench.client.cli.CliVersion import com.jaytux.phoebench.common.TimeUnit import io.ktor.util.reflect.* import io.ktor.utils.io.* @@ -23,6 +24,10 @@ object CLI { help = "Disable all interactive input (makes arguments marked with (*) mandatory)" ).flag(default = false) + init { + versionOption(CliVersion.VERSION) + } + override fun run() { val sub = currentContext.invokedSubcommand if(sub == null) { @@ -208,6 +213,13 @@ object CLI { override fun run() = ProjectHandlers.newData(parent.finder, version, warmup, measurement, unit, input, hardware) } } + + @Suppress("unused") + class Version : CliktCommand(name = "version") { + override fun run() { + echo("phoebench-cli version ${CliVersion.VERSION}") + } + } } fun main(args: Array) { diff --git a/clients/compose/build.gradle.kts b/clients/compose/build.gradle.kts index 0c0c330..112cb7e 100644 --- a/clients/compose/build.gradle.kts +++ b/clients/compose/build.gradle.kts @@ -10,6 +10,24 @@ plugins { alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.serialization) + alias(libs.plugins.buildconfig) +} + +version = "1.0.1" +val versionDirectory = layout.buildDirectory.dir("generated/sources/version") + +val generateVersion = tasks.register("clientVersion") { + doFirst { + val outFile = versionDirectory.get().file("com/jaytux/phoebench/client/Version.kt").asFile + outFile.parentFile.mkdirs() + outFile.writeText(""" + package com.jaytux.phoebench.client + + object ClientVersion { + const val VERSION="${rootProject.version}" + } + """.trimIndent()) + } } kotlin { @@ -86,6 +104,20 @@ kotlin { wasmJsMain.dependencies { implementation(libs.ktor.client.js) } + + commonMain { + kotlin { + srcDir(versionDirectory) + } + } + } + + targets.all { + compilations.all { + compileTaskProvider.configure { + dependsOn(generateVersion) + } + } } } @@ -96,7 +128,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "com.jaytux.phoebench.clients" - packageVersion = rootProject.version.toString().split('-')[0] + packageVersion = version.toString() jvmArgs += listOf("--enable-native-access=ALL-UNNAMED") includeAllModules = true @@ -105,4 +137,33 @@ compose.desktop { } } } +} + +fun envValue(key: String): String? { + val fromEnv = providers.environmentVariable(key).orNull + if(fromEnv != null) return fromEnv + + val envFile = rootProject.file(".env") + if(!envFile.exists()) return null + + return envFile.useLines { lines -> + lines.map { it.trim() }.filter { it.isNotBlank() && !it.startsWith('#') && '=' in it } + .map { line -> + val (k, v) = line.split('=', limit = 2) + k.trim() to v.trim().removeSurrounding("\"").removeSurrounding("'") + } + .firstOrNull { it.first == key }?.second + } +} + +buildConfig { + sourceSets.getByName("wasmJsMain") { + val serverUrl = envValue("WASM_SERVER_URL") ?: + throw GradleException("MISSING CONFIGURATION: WASM_SERVER_URL. Please set it as environment variable or `.env` variable.") + + className("WasmConfig") + packageName("com.jaytux.phoebench.clients") + useKotlinOutput() + buildConfigField("SERVER_URL", serverUrl) + } } \ No newline at end of file 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 aa44dca..7156754 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,5 +1,6 @@ package com.jaytux.phoebench.clients +import androidx.compose.runtime.Composable import androidx.compose.ui.platform.ClipEntry import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig @@ -20,4 +21,9 @@ expect fun persistentStore(): IStore expect fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient -expect suspend fun String.toClipEntry(): ClipEntry \ No newline at end of file +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 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 6a2e058..94436a4 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 @@ -77,17 +77,49 @@ fun Pair.fmtRange() = "Between ${first.fmt()} and ${second.fmt infix fun Instant.inRange(range: Pair) = range.first <= this && this <= range.second -fun Float.fmt(): String { - val integer = toInt() - val decimals = this - integer - val decInt = (decimals * 1000).roundToInt().toFloat() / 1000f - return (integer + decInt).toString() -} - inline fun > List.insort(elem: T, crossinline sortBy: (T) -> X): List { val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy) if(insertionPoint >= 0) return this val index = -insertionPoint - 1 return toMutableList().apply { add(index, elem) } +} + +fun List.geomean(): Float { + return (size.toFloat() / sumOf { 1.0 / it.toDouble() }).toFloat() +} + +infix fun Pair.app(t3: T3) = Triple(first, second, t3) + +fun List>.unzip(): Triple, List, List> { + val l1 = ArrayList(size) + val l2 = ArrayList(size) + val l3 = ArrayList(size) + + for((t1, t2, t3) in this) { + l1.add(t1) + l2.add(t2) + l3.add(t3) + } + + return l1 to l2 app l3 +} + +data class Tuple4(val t1: T1, val t2: T2, val t3: T3, val t4: T4) +infix fun Triple.app(t4: T4) = Tuple4(first, second, third, t4) + +fun List>.unzip(): Tuple4, List, List, List> { + val l1 = ArrayList(size) + val l2 = ArrayList(size) + val l3 = ArrayList(size) + val l4 = ArrayList(size) + + for((t1, t2, t3, t4) in this) { + l1.add(t1) + l2.add(t2) + l3.add(t3) + l4.add(t4) + } + + return l1 to l2 app l3 app l4 } \ No newline at end of file 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 b85a427..1db0d8c 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 @@ -25,7 +25,7 @@ interface IProjectRepo { 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 deleteLabel(id: Uuid): Either + suspend fun deleteVersion(id: Uuid): Either suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List, measurements: List, unit: TimeUnit, input: String, hardware: String): Either @@ -48,7 +48,7 @@ interface IProjectRepo { override suspend fun updateVersion(id: Uuid, name: String?, color: Color?, meta: String?): Either = _client.callRoute(Routes.Version.update, id to PartialVersionRequest(name, color?.hexString(), meta)).ignoreValue() - override suspend fun deleteLabel(id: Uuid): Either = + override suspend fun deleteVersion(id: Uuid): Either = _client.callRoute(Routes.Version.delete, id).ignoreValue() override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List, 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 3fbef01..320ef78 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 @@ -174,9 +174,9 @@ class ProjectVM( } } - fun deleteLabel(id: Uuid) { + fun deleteVersion(id: Uuid) { withScope { - _repo.deleteLabel(id).snackOr { + _repo.deleteVersion(id).snackOr { if(_job == null) _labels.value -= id } 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 c7fe521..c89e74d 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 @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -28,13 +27,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold -import androidx.compose.material3.SecondaryScrollableTabRow import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface -import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -49,7 +45,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalDensity @@ -60,7 +55,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.lifecycle.viewmodel.compose.viewModel import com.composables.icons.lucide.ChevronLeft -import com.composables.icons.lucide.Delete import com.composables.icons.lucide.LogOut import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Pencil @@ -68,8 +62,10 @@ import com.composables.icons.lucide.Plus import com.composables.icons.lucide.ShieldPlus import com.composables.icons.lucide.Trash import com.composables.icons.lucide.X +import com.jaytux.phoebench.client.ClientVersion import com.jaytux.phoebench.clients.AuthProvider import com.jaytux.phoebench.clients.Client +import com.jaytux.phoebench.clients.ServerSelect import com.jaytux.phoebench.clients.SnackProvider import com.jaytux.phoebench.clients.data.HomeVM import com.jaytux.phoebench.clients.data.mutableStateSetOf @@ -82,13 +78,11 @@ import com.jaytux.phoebench.common.HomeResponse import com.jaytux.phoebench.common.LoginRequest import com.jaytux.phoebench.common.NamedID import com.jaytux.phoebench.common.ProtocolVersion -import com.jaytux.phoebench.common.RefreshRequest import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.SignupRequest import com.jaytux.phoebench.common.UserListResponse import com.jaytux.phoebench.common.fold import kotlinx.coroutines.launch -import kotlinx.datetime.TimeZone import kotlin.uuid.Uuid @Composable @@ -165,6 +159,16 @@ fun RootView(auth: AuthProvider, client: Client) = Surface(tonalElevation = 1.dp } } +@Composable +fun DefaultServerSelect(server: String, onUpdate: (String) -> Unit) { + OutlinedTextField(server, onUpdate, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true) +} + +fun constantServerSelect(server: String): (@Composable (server: String, onUpdate: (String) -> Unit) -> Unit) = { _, onUpdate -> + onUpdate(server) + OutlinedTextField(server, {}, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true, enabled = false) +} + @Composable fun LoginView(server: String?, error: String?, onSwitchSignup: () -> Unit, onLogin: (server: String, user: String, pass: String) -> Unit) { var serverUrl by remember { mutableStateOf(server ?: "https://") } @@ -176,7 +180,8 @@ fun LoginView(server: String?, error: String?, onSwitchSignup: () -> Unit, onLog Column(Modifier.padding(5.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min), horizontalAlignment = Alignment.CenterHorizontally) { Text("Log in", style = MaterialTheme.typography.headlineMedium) - OutlinedTextField(serverUrl, { serverUrl = it }, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true) + ServerSelect(serverUrl) { serverUrl = it } +// OutlinedTextField(serverUrl, { serverUrl = it }, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true) OutlinedTextField(username, { username = it }, Modifier.fillMaxWidth(), label = { Text("Username") }, singleLine = true) OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") }, visualTransformation = PasswordVisualTransformation(), singleLine = true) @@ -250,7 +255,7 @@ fun AuthenticatedRoot() { Scaffold( topBar = { TopAppBar( - title = { Text("PhoeBench", style = MaterialTheme.typography.headlineLarge) }, + title = { Text("PhoeBench v${ClientVersion.VERSION}", style = MaterialTheme.typography.headlineLarge) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer, titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer 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 c583e4c..d1dc46c 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 @@ -25,33 +25,18 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.lifecycle.viewmodel.compose.viewModel import com.composables.icons.lucide.* +import com.jaytux.phoebench.clients.* import com.jaytux.phoebench.clients.data.ProjectVM import com.jaytux.phoebench.clients.data.mutableStateSetFrom import com.jaytux.phoebench.clients.data.mutableStateSetOf -import com.jaytux.phoebench.clients.dualLerp -import com.jaytux.phoebench.clients.fmt -import com.jaytux.phoebench.clients.fmtRange -import com.jaytux.phoebench.clients.inRange -import com.jaytux.phoebench.clients.nonEq -import com.jaytux.phoebench.clients.randomColor -import com.jaytux.phoebench.common.Either -import com.jaytux.phoebench.common.TimeUnit -import com.jaytux.phoebench.common.asError -import com.jaytux.phoebench.common.asValue -import com.jaytux.phoebench.common.error -import com.jaytux.phoebench.common.isValue -import com.jaytux.phoebench.common.value +import com.jaytux.phoebench.common.* import com.kborowy.colorpicker.KolorPicker +import io.github.koalaplot.core.bar.DefaultBar +import io.github.koalaplot.core.bar.VerticalBarPlot import io.github.koalaplot.core.line.LinePlot import io.github.koalaplot.core.style.KoalaPlotTheme import io.github.koalaplot.core.style.LineStyle -import io.github.koalaplot.core.xygraph.AxisContent -import io.github.koalaplot.core.xygraph.DefaultPoint -import io.github.koalaplot.core.xygraph.XYGraph -import io.github.koalaplot.core.xygraph.autoScaleRange -import io.github.koalaplot.core.xygraph.rememberAxisStyle -import io.github.koalaplot.core.xygraph.rememberFloatLinearAxisModel -import io.github.koalaplot.core.xygraph.rememberGridStyle +import io.github.koalaplot.core.xygraph.* import kotlin.time.Instant import kotlin.uuid.Uuid @@ -248,8 +233,11 @@ fun QuickVersion(lbl: ProjectVM.Version, hollow: Boolean = false) { color = LocalContentColor.current.copy(alpha = 0.75f), ) } - Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, - color = LocalContentColor.current.copy(0.75f)) + if(lbl.meta.isNotBlank()) + Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(0.75f)) + else + Text("(no meta-data for version)", style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(0.5f)) } } } @@ -394,6 +382,37 @@ fun ProjectPlotArea(vm: ProjectVM){ val entries by vm.entries val versions by vm.versions val editable by vm.editable + val allInputs by vm.inputs + val allHardware by vm.hardware + + var filteredEntries by remember { mutableStateOf(entries) } + var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) } + var timeMax by remember { mutableStateOf(Instant.DISTANT_FUTURE) } + var timeFilter by remember { mutableStateOf(0f..1f) } + var timeFilterString by remember { mutableStateOf("") } + val versionFilter = remember(versions) { mutableStateSetFrom(versions.values) } + val labelVersionFilter by versionFilter.revision + val enabledInputs = remember { mutableStateSetOf() } + val enabledHardware = remember { mutableStateSetOf() } + var filterInput by remember { mutableStateOf(null) } + var filterHardware by remember { mutableStateOf(null) } + + LaunchedEffect(entries, allInputs, allHardware, timeFilter, timeMin, timeMax, labelVersionFilter, filterInput, filterHardware) { + val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive) + enabledInputs.clear() + enabledHardware.clear() + + filteredEntries = entries.filter { entry -> + if(entry.label !in versionFilter) return@filter false + if(!(entry.timeStamp inRange timeRange)) return@filter false + if(filterInput != null && entry.input != filterInput) return@filter false + if(filterHardware != null && entry.hardware != filterHardware) return@filter false + + enabledInputs.add(entry.input) + enabledHardware.add(entry.hardware) + true + } + } var displayWarmups by remember { mutableStateOf(false) } var currentUnit by remember { mutableStateOf(TimeUnit.SECONDS) } @@ -402,42 +421,14 @@ fun ProjectPlotArea(vm: ProjectVM){ var yMin by remember { mutableStateOf(0f) } var yMax by remember { mutableStateOf(1f) } - var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) } - var timeMax by remember { mutableStateOf(Instant.DISTANT_FUTURE) } - var timeFilter by remember { mutableStateOf(0f..1f) } - var timeFilterString by remember { mutableStateOf("") } - - val versionFilter = remember(versions) { mutableStateSetFrom(versions.values) } - val labelVersionFilter by versionFilter.revision - - val allInputs by vm.inputs - val allHardware by vm.hardware - val enabledInputs = remember { mutableStateSetOf() } - val enabledHardware = remember { mutableStateSetOf() } - var filterInput by remember { mutableStateOf(null) } - var filterHardware by remember { mutableStateOf(null) } - - LaunchedEffect(entries, displayWarmups, currentUnit, timeFilter, timeMin, timeMax, labelVersionFilter, filterInput, filterHardware) { + LaunchedEffect(filteredEntries, displayWarmups, currentUnit) { var maxX = 0f var minY = 0f var maxY = 0f - val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive) - enabledInputs.clear() - enabledHardware.clear() - renderableEntries = entries.mapNotNull { entry -> + renderableEntries = filteredEntries.map { entry -> val use = if(displayWarmups) entry.warmups else entry.measurements - - if(entry.label !in versionFilter) return@mapNotNull null - if(!(entry.timeStamp inRange timeRange)) return@mapNotNull null - if(filterInput != null && entry.input != filterInput) return@mapNotNull null - if(filterHardware != null && entry.hardware != filterHardware) return@mapNotNull null - maxX = maxOf(maxX, use.size.toFloat()) - - enabledInputs.add(entry.input) - enabledHardware.add(entry.hardware) - use.mapIndexed { idx, it -> val converted = entry.nativeUnit.convertTo(currentUnit, it) minY = minOf(minY, converted) @@ -451,6 +442,12 @@ fun ProjectPlotArea(vm: ProjectVM){ yMax = maxY } + + var deletingVersion by remember { mutableStateOf(null) } + var baseline by remember { mutableStateOf(null) } + var baselineComparison by remember { mutableStateOf>?>(null) } + var baselineMax by remember { mutableStateOf(2.0f) } + LaunchedEffect(entries) { timeMin = entries.minOfOrNull { it.timeStamp } ?: Instant.DISTANT_PAST timeMax = entries.maxOfOrNull { it.timeStamp } ?: Instant.DISTANT_FUTURE @@ -460,8 +457,39 @@ fun ProjectPlotArea(vm: ProjectVM){ timeFilterString = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive).fmtRange() } + LaunchedEffect(filteredEntries, baseline, currentUnit) { + var max = 0.0f + baselineComparison = baseline?.let { bl -> + val perVersion = mutableMapOf>() + filteredEntries.forEach { entry -> + val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum() + perVersion[entry.label] = perVersion[entry.label]?.let { + (previous, prCount) -> (previous + corrected) to (prCount + entry.measurements.size) + } ?: (corrected to entry.measurements.size.toFloat()) + } + + val means = perVersion.mapValues { (_, v) -> v.first / v.second } + val baselineData = means[bl] ?: return@let null + means.mapNotNull { (ver, data) -> + val speedup = baselineData / data + max = maxOf(max, speedup) + ver.name to speedup app ver.uiColor app data + } + } + baselineMax = max + } + + val style = rememberAxisStyle() + val lineColor = SolidColor(Color.LightGray.copy(alpha = 0.25f)) + val gridStyle = rememberGridStyle( + horizontalMajorStyle = KoalaPlotTheme.axis.majorGridlineStyle?.copy(brush = lineColor), + horizontalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor), + verticalMajorStyle = KoalaPlotTheme.axis.majorGridlineStyle?.copy(brush = lineColor), + verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor) + ) + Row(Modifier.fillMaxSize().padding(20.dp)) { - Box(Modifier.weight(0.66f).fillMaxHeight()) { + Row(Modifier.weight(0.66f).fillMaxHeight()) { if(entries.isEmpty()) { Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) @@ -518,24 +546,16 @@ fun ProjectPlotArea(vm: ProjectVM){ } Spacer(Modifier.height(15.dp)) - val style = rememberAxisStyle() - val lineColor = SolidColor(Color.LightGray.copy(alpha = 0.25f)) - val gridStyle = rememberGridStyle( - horizontalMajorStyle = KoalaPlotTheme.axis.majorGridlineStyle?.copy(brush = lineColor), - horizontalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor), - verticalMajorStyle = KoalaPlotTheme.axis.majorGridlineStyle?.copy(brush = lineColor), - verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor) - ) XYGraph( rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()), rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()), xAxisContent = AxisContent( - labels = { AxisLabel(it.fmt()) }, + labels = { AxisLabel(format(it)) }, title = {}, style = style ), yAxisContent = AxisContent( - labels = { AxisLabel(it.fmt()) }, + labels = { AxisLabel(format(it)) }, title = {}, style = style ), @@ -552,40 +572,57 @@ fun ProjectPlotArea(vm: ProjectVM){ Spacer(Modifier.width(20.dp)) - Row(Modifier.weight(0.33f).fillMaxHeight()) { - Column(Modifier.weight(0.5f)) { - Text("Versions", style = MaterialTheme.typography.headlineSmall) - Text("Click a version to toggle its visibility.", fontStyle = FontStyle.Italic, - color = LocalContentColor.current.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall) - Spacer(Modifier.height(10.dp)) - if(versions.isEmpty()) { - Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { - Text("No versions yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) + Column(Modifier.weight(0.33f).fillMaxHeight()) { + Row { + Column(Modifier.weight(0.5f)) { + Text("Versions", style = MaterialTheme.typography.headlineSmall) + Text("Click a version to toggle its visibility.", fontStyle = FontStyle.Italic, + color = LocalContentColor.current.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(10.dp)) + if(versions.isEmpty()) { + Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { + Text("No versions yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) + } } - } - else { - LazyColumn(Modifier.padding(start = 5.dp)) { - items(versions.toList().sortedBy { it.second.name }) { (_, lbl) -> - Box(Modifier.fillMaxWidth().clickable { - if(versionFilter.size == versions.size) { - versionFilter.clear() - versionFilter.add(lbl) - } - else { - versionFilter.toggle(lbl) - if(versionFilter.isEmpty()) versionFilter.addAll(versions.values) - } - }) { - Box { - QuickVersion(lbl, lbl !in versionFilter) + else { + LazyColumn(Modifier.padding(start = 5.dp)) { + items(versions.toList().sortedBy { it.second.name }) { (_, lbl) -> + Box(Modifier.fillMaxWidth().clickable { + baseline = null + if(versionFilter.size == versions.size) { + versionFilter.clear() + versionFilter.add(lbl) + } + else { + versionFilter.toggle(lbl) + if(versionFilter.isEmpty()) versionFilter.addAll(versions.values) + } + }) { + Row { + Box(Modifier.weight(1f)) { + QuickVersion(lbl, lbl !in versionFilter) - if(lbl !in versionFilter) { - Box(Modifier.matchParentSize()) { - HorizontalDivider( - Modifier.fillMaxWidth().align(Alignment.Center), - thickness = 3.dp, - color = LocalContentColor.current - ) + if (lbl !in versionFilter) { + Box(Modifier.matchParentSize()) { + HorizontalDivider( + Modifier.fillMaxWidth().align(Alignment.Center), + thickness = 3.dp, + color = LocalContentColor.current + ) + } + } + } + IconButton({ baseline = if(baseline == lbl) null else lbl }, + Modifier.align(Alignment.CenterVertically), enabled = lbl in versionFilter) { + if(baseline == lbl) { + Surface(Modifier.padding(5.dp), color = MaterialTheme.colorScheme.inverseSurface, shape = MaterialTheme.shapes.large) { + Icon(Lucide.RulerDimensionLine, "Use as baseline", Modifier.padding(5.dp)) + } + } + else Icon(Lucide.RulerDimensionLine, "Use as baseline") + } + IconButton({ deletingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) { + Icon(Lucide.Trash, "Delete version") } } } @@ -593,48 +630,111 @@ fun ProjectPlotArea(vm: ProjectVM){ } } } + Spacer(Modifier.width(15.dp)) + LazyColumn(Modifier.weight(0.5f)) { + item { Text("Inputs", style = MaterialTheme.typography.headlineSmall) } + item { + Text("Click an input to set or clear the filter.", fontStyle = FontStyle.Italic, + color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall) + } + + item { Spacer(Modifier.height(10.dp)) } + + items((allInputs intersect enabledInputs.toSet()).toList()) { + Row(Modifier.fillMaxWidth().clickable { filterInput = if(filterInput == null) it else null }) { + Text(it, Modifier.padding(start = 5.dp)) + } + } + + items((allInputs - enabledInputs.toSet()).toList()) { + Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f)) + } + + item { Spacer(Modifier.height(10.dp)) } + item { HorizontalDivider(Modifier.height(1.dp)) } + item { Spacer(Modifier.height(10.dp)) } + + item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) } + item { + Text("Click an item to set or clear the filter.", fontStyle = FontStyle.Italic, + color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall) + } + + item { Spacer(Modifier.height(10.dp)) } + + items((allHardware intersect enabledHardware.toSet()).toList()) { + Row(Modifier.fillMaxWidth().clickable { filterHardware = if(filterHardware == null) it else null }) { + Text(it, Modifier.padding(start = 5.dp)) + } + } + + items((allHardware - enabledHardware.toSet()).toList()) { + Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f)) + } + } } - Spacer(Modifier.width(15.dp)) - LazyColumn(Modifier.weight(0.5f)) { - item { Text("Inputs", style = MaterialTheme.typography.headlineSmall) } - item { - Text("Click an input to set or clear the filter.", fontStyle = FontStyle.Italic, - color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall) - } - item { Spacer(Modifier.height(10.dp)) } + Column(Modifier.weight(1f)) { + baselineComparison?.let { blc -> + Spacer(Modifier.height(10.dp)) + HorizontalDivider(Modifier.height(1.dp)) + Spacer(Modifier.height(10.dp)) + Text("Speedup compared to ${baseline?.name ?: ""}", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(10.dp)) - items((allInputs intersect enabledInputs.toSet()).toList()) { - Row(Modifier.fillMaxWidth().clickable { filterInput = if(filterInput == null) it else null }) { - Text(it, Modifier.padding(start = 5.dp)) + val (xs, ys, colors) = blc.unzip() + + XYGraph( + xAxisModel = remember(blc) { CategoryAxisModel(xs) }, + yAxisModel = rememberFloatLinearAxisModel(0f..baselineMax), + xAxisContent = AxisContent( + labels = { AxisLabel(it) }, + title = {}, + style = style + ), + yAxisContent = AxisContent( + labels = { AxisLabel(format(it)) }, + title = {}, + style = style + ), + modifier = Modifier.weight(1f), + gridStyle = gridStyle + ) { + VerticalBarPlot( + xData = xs, + yData = ys, + bar = { x, _, _ -> DefaultBar(brush = SolidColor(colors[x]), modifier = Modifier.fillMaxWidth()) } + ) + + blc.forEach { (x, y, _, time) -> + XYAnnotation(Point(x, y), AnchorPoint.TopCenter) { + Text("${format(y)}x\n${format(time)} ${currentUnit.disp}", Modifier.padding(top = 5.dp)) + } + } } } + } + } + } - items((allInputs - enabledInputs.toSet()).toList()) { - Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f)) - } + deletingVersion?.let { rmV -> + ConfirmDeleteVersionDialog(rmV, { deletingVersion = null }) { + vm.deleteVersion(rmV.id) + } + } +} - item { Spacer(Modifier.height(10.dp)) } - item { HorizontalDivider(Modifier.height(1.dp)) } - item { Spacer(Modifier.height(10.dp)) } - - item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) } - item { - Text("Click an item to set or clear the filter.", fontStyle = FontStyle.Italic, - color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall) - } - - item { Spacer(Modifier.height(10.dp)) } - - items((allHardware intersect enabledHardware.toSet()).toList()) { - Row(Modifier.fillMaxWidth().clickable { filterHardware = if(filterHardware == null) it else null }) { - Text(it, Modifier.padding(start = 5.dp)) - } - } - - items((allHardware - enabledHardware.toSet()).toList()) { - Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f)) - } +@Composable +fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit, onDelete: () -> Unit) { + Dialog(onDismissRequest = onCancel) { + Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) { + Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(10.dp)) + Text("Are you sure you want to delete $version?") + Text("This will also remove all data associated with this version.") + Spacer(Modifier.height(10.dp)) + CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete") } } } 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 627f333..842f55e 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 @@ -1,8 +1,10 @@ package com.jaytux.phoebench.clients +import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.platform.ClipEntry import com.github.javakeyring.Keyring +import com.jaytux.phoebench.clients.ui.DefaultServerSelect import io.ktor.client.* import io.ktor.client.engine.okhttp.* import kotlinx.serialization.KSerializer @@ -55,4 +57,9 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient = @OptIn(ExperimentalComposeUiApi::class) actual suspend fun String.toClipEntry(): ClipEntry = - ClipEntry(StringSelection(this)) \ No newline at end of file + ClipEntry(StringSelection(this)) + +actual fun format(f: Float): String = String.format("%.03f", f) + +@Composable +actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate) \ 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 9832c1c..2400806 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,8 @@ package com.jaytux.phoebench.clients +import androidx.compose.runtime.Composable import androidx.compose.ui.platform.ClipEntry +import com.jaytux.phoebench.clients.ui.constantServerSelect import io.ktor.client.* import io.ktor.client.engine.js.* import kotlinx.browser.window @@ -46,4 +48,15 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient = } actual suspend fun String.toClipEntry(): ClipEntry = - ClipEntry.withPlainText(this) \ No newline at end of file + ClipEntry.withPlainText(this) + +@OptIn(ExperimentalWasmJsInterop::class) +@JsFun("(num) => num.toFixed(3)") +private external fun jsFormat(num: Double): String + +actual fun format(f: Float): String = jsFormat(f.toDouble()) + +@Composable +actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) { + constantServerSelect(WasmConfig.SERVER_URL)(server, onUpdate) +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03ad544..da22431 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ koala-plot = "0.12.0" kolor-picker = "2.1.0" shadow = "9.3.0" clikt = "5.0.3" +buildconfig = "6.0.10" [libraries] androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } @@ -94,4 +95,5 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } -ktor = { id = "io.ktor.plugin", version.ref = "ktor" } \ No newline at end of file +ktor = { id = "io.ktor.plugin", version.ref = "ktor" } +buildconfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildconfig" } \ No newline at end of file diff --git a/server/build.gradle.kts b/server/build.gradle.kts index f7285b6..a24da5c 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -7,11 +7,27 @@ plugins { } group = "com.jaytux.phoebench" +version = rootProject.version repositories { mavenCentral() } +val versionDirectory = layout.buildDirectory.dir("generated/sources/version") +val generateVersion = tasks.register("serverVersion") { + doFirst { + val outFile = versionDirectory.get().file("com/jaytux/phoebench/server/Version.kt").asFile + outFile.parentFile.mkdirs() + outFile.writeText(""" + package com.jaytux.phoebench.server + + object ServerVersion { + const val VERSION="${rootProject.version}" + } + """.trimIndent()) + } +} + dependencies { implementation(libs.exposed.core) implementation(libs.exposed.dao) @@ -63,6 +79,18 @@ kotlin { freeCompilerArgs.add("-Xcontext-parameters") optIn.add("kotlin.uuid.ExperimentalUuidApi") } + + sourceSets { + val main by getting { + kotlin { + srcDir(versionDirectory) + } + } + } + + tasks.compileKotlin { + dependsOn(generateVersion) + } } application { 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 317fd30..e7bc679 100644 --- a/server/src/main/kotlin/com/jaytux/phoebench/server/Main.kt +++ b/server/src/main/kotlin/com/jaytux/phoebench/server/Main.kt @@ -25,24 +25,14 @@ import kotlin.uuid.Uuid fun Application.module() { DB.db - println("Entering module...") + println("Entering module for PhoeBench Server version ${ServerVersion.VERSION}...") install(ContentNegotiation) { json() } - install(StatusPages) { - status(HttpStatusCode.Forbidden) { call, status -> - call.respond(status, ErrorResponse("Access Forbidden: CORS failed.")) - } - status(HttpStatusCode.Unauthorized) { call, status -> - call.respond(status, ErrorResponse("Unauthorized/unauthenticated.")) - } - } - install(SSE) { - // - } + install(SSE) {} val allowLocalhost = environment.config.propertyOrNull("ktor.cors.enableLocalhostOn")?.getString() ?: "0" val safeOrigin = environment.config.propertyOrNull("ktor.cors.browserOrigin")?.getString() @@ -52,6 +42,7 @@ fun Application.module() { allowMethod(HttpMethod.Patch) allowMethod(HttpMethod.Get) allowMethod(HttpMethod.Post) + allowHeader(HttpHeaders.Accept) allowHeader(HttpHeaders.Authorization) allowHeader(HttpHeaders.ContentType) exposeHeader(HttpHeaders.ContentType) @@ -76,6 +67,15 @@ fun Application.module() { } } + install(StatusPages) { + status(HttpStatusCode.Forbidden) { call, status -> + call.respond(status, ErrorResponse("Access Forbidden: CORS failed.")) + } + status(HttpStatusCode.Unauthorized) { call, status -> + call.respond(status, ErrorResponse("Unauthorized/unauthenticated.")) + } + } + install(AutoHeadResponse) install(CallLogging) diff --git a/server/src/main/resources/simplelogger.properties b/server/src/main/resources/simplelogger.properties index e69de29..7a7a0c0 100644 --- a/server/src/main/resources/simplelogger.properties +++ b/server/src/main/resources/simplelogger.properties @@ -0,0 +1 @@ +org.slf4j.simpleLogger.log.io.ktor.server.plugins.cors=TRACE \ No newline at end of file