2 Commits
Author SHA1 Message Date
jaytux 07225ae461 UI improvements, CLI fallbacks 2026-08-24 17:07:19 +02:00
jaytux 462c6da965 CLI client: more robust 2026-08-13 15:22:53 +02:00
20 changed files with 528 additions and 187 deletions
+1 -1
View File
@@ -12,4 +12,4 @@ repositories {
mavenCentral()
}
version = "1.0.0-dev"
version = "1.0.0"
+28 -1
View File
@@ -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<Task>("serverVersion") {
doFirst {
val outFile = versionDirectory.get().file("com/jaytux/phoebench/client/cli/Version.kt").asFile
outFile.parentFile.mkdirs()
outFile.writeText("""
package com.jaytux.phoebench.client.cli
object CliVersion {
const val VERSION="${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<ShadowJar> {
@@ -6,6 +6,7 @@ import com.github.ajalt.clikt.parameters.groups.single
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.float
import com.github.ajalt.clikt.parameters.types.inputStream
import com.jaytux.phoebench.client.cli.CliVersion
import com.jaytux.phoebench.common.TimeUnit
import io.ktor.util.reflect.*
import io.ktor.utils.io.*
@@ -23,6 +24,10 @@ object CLI {
help = "Disable all interactive input (makes arguments marked with (*) mandatory)"
).flag(default = false)
init {
versionOption(CliVersion.VERSION)
}
override fun run() {
val sub = currentContext.invokedSubcommand
if(sub == null) {
@@ -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<String>) {
@@ -80,9 +80,10 @@ object Client {
val res = route.call(client, body)
res
} catch (e: CancellationException) {
println("Coroutine calling ${route.verb} ${route.pattern} was cancelled.")
ErrorResponse("Coroutine calling ${route.verb} ${route.pattern} was cancelled.").error()
} catch (e: Exception) {
// println("Call to ${route.pattern} ran into an exception")
println("Call to ${route.pattern} ran into an exception: ${e.message} (${e::class.simpleName})")
ErrorResponse(e.message ?: "Unknown error.").error()
}
}
@@ -1,7 +1,10 @@
package com.jaytux.phoebench.clients.cli
import kotlin.system.exitProcess
fun main(args: Array<String>) {
Client.forceGloballyInitialized()
CLI.main(args)
Client.shutdown()
exitProcess(0) // because D-Bus threads...
}
@@ -1,14 +1,63 @@
package com.jaytux.phoebench.clients.cli
import com.github.javakeyring.BackendNotSupportedException
import com.github.javakeyring.Keyring
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import java.util.prefs.Preferences
import kotlin.uuid.Uuid
object PersistentStorage {
private val json = Json
const val SERVICE = "com.jaytux.phoebench.cli"
private var _backend: IBackend = KeyringBackend
interface IBackend {
fun read(key: String): String?
fun write(key: String, value: String)
fun erase(key: String)
}
private object KeyringBackend : IBackend {
override fun read(key: String): String? = try {
Keyring.create().use { it.getPassword(SERVICE, key) }
} catch(e: Exception) {
_backend = PreferencesBackend
_backend.read(key)
}
override fun write(key: String, value: String) {
try {
Keyring.create().use { it.setPassword(SERVICE, key, value) }
} catch(e: Exception) {
_backend = PreferencesBackend
_backend.write(key, value)
}
}
override fun erase(key: String) {
try {
Keyring.create().use { it.deletePassword(SERVICE, key) }
} catch (e: Exception) {
_backend = PreferencesBackend
_backend.erase(key)
}
}
}
private object PreferencesBackend : IBackend {
private val prefs = Preferences.userNodeForPackage(this::class.java)
override fun read(key: String): String? = prefs.get(key, null)
override fun write(key: String, value: String) {
prefs.put(key, value)
}
override fun erase(key: String) {
prefs.remove(key)
}
}
class StoredProperty<T>(
private val _key: String,
@@ -20,21 +69,15 @@ object PersistentStorage {
)
fun load(): T? = runCatching {
Keyring.create().use {
it.getPassword(SERVICE, _key)
}
_backend.read(_key)
}.getOrNull()?.let { _fromString(it) }
fun save(value: T) = runCatching {
val keyring = Keyring.create().use {
it.setPassword(SERVICE, _key, _toString(value))
}
_backend.write(_key, _toString(value))
}.onFailure { println("Failed to write to OS keyring: ${it.message}") }.ignore()
fun erase() = runCatching {
val keyring = Keyring.create().use {
it.deletePassword(SERVICE, _key)
}
_backend.erase(_key)
}.ignore()
}
@@ -67,15 +67,15 @@ object ProjectHandlers {
System.err.println("Failed to get project details: ${it.msg}")
}) {
println("Project ${it.owner.name}/${it.name} [${it.id}]:")
println("${it.usedLabels.size} labels:")
it.usedLabels.forEach { l ->
println(" - [${l.id}] ${l.name} (with color ${l.color})")
println("${it.usedLabels.size} versions:")
it.usedLabels.forEach { v ->
println(" - [${v.id}] VERSION ${v.name} (with color ${v.color}); metadata: ${v.meta}")
}
val map = it.usedLabels.associateBy { l -> l.id }
println("\n${it.entries.size} entries:")
it.entries.forEach { e ->
println(" - [${e.id}] Entry labeled ${map[e.labelId]?.name ?: "<invalid label>"} at ${e.timestamp.fmt()} " +
println(" - [${e.id}] ENTRY versioned ${map[e.labelId]?.name ?: "<invalid version>"} at ${e.timestamp.fmt()} " +
"(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})")
}
}
@@ -94,7 +94,7 @@ object ProjectHandlers {
fun newVersion(name: String?, color: String?, project: CLI.Commands.Project.IProjectIdentification?, version: String?) {
val id = ensureProjectIdentification(project)
val useName = name.maybePrompt("label name") { it }
val useName = name.maybePrompt("version name") { it }
val useColor = color.maybePrompt("color") {
val check = it.length == 7 && it[0] == '#' && it.substring(1, it.length).all { c -> c.isDigit() || c in "ABCDEF" }
if(check) it
@@ -110,7 +110,7 @@ object ProjectHandlers {
Client.callRoute(Routes.Version.new, VersionRequest(useName, useColor, useVersion, projectId))
}
}.fold({
System.err.println("Could not create label: ${it.msg}")
System.err.println("Could not create version: ${it.msg}")
}) {
println("Label ${it.name} (${it.color}) [${it.id}] created.")
}
@@ -164,7 +164,7 @@ object ProjectHandlers {
is CLI.Commands.Project.ID -> versionId.id.value()
is CLI.Commands.Project.VersionName -> Client.callRoute(Routes.Project.get, pId).bind {
it.usedLabels.firstOrNull { l -> l.name == versionId.name }?.id?.value()
?: ErrorResponse("Label ${versionId.name} does not exist in this project.").error()
?: ErrorResponse("Version ${versionId.name} does not exist in this project.").error()
}
}.map { pId to it }
}.bind { (pId, vId) ->
+62 -1
View File
@@ -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<Task>("clientVersion") {
doFirst {
val outFile = versionDirectory.get().file("com/jaytux/phoebench/client/Version.kt").asFile
outFile.parentFile.mkdirs()
outFile.writeText("""
package com.jaytux.phoebench.client
object ClientVersion {
const val VERSION="${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)
}
}
@@ -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
expect suspend fun String.toClipEntry(): ClipEntry
expect fun format(f: Float): String
@Composable
expect fun ServerSelect(server: String, onUpdate: (String) -> Unit)
@@ -77,17 +77,49 @@ fun Pair<Instant, Instant>.fmtRange() = "Between ${first.fmt()} and ${second.fmt
infix fun Instant.inRange(range: Pair<Instant, Instant>) = range.first <= this && this <= range.second
fun Float.fmt(): String {
val integer = toInt()
val decimals = this - integer
val decInt = (decimals * 1000).roundToInt().toFloat() / 1000f
return (integer + decInt).toString()
}
inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T) -> X): List<T> {
val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy)
if(insertionPoint >= 0) return this
val index = -insertionPoint - 1
return toMutableList().apply { add(index, elem) }
}
fun List<Float>.geomean(): Float {
return (size.toFloat() / sumOf { 1.0 / it.toDouble() }).toFloat()
}
infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
fun <T1, T2, T3> List<Triple<T1, T2, T3>>.unzip(): Triple<List<T1>, List<T2>, List<T3>> {
val l1 = ArrayList<T1>(size)
val l2 = ArrayList<T2>(size)
val l3 = ArrayList<T3>(size)
for((t1, t2, t3) in this) {
l1.add(t1)
l2.add(t2)
l3.add(t3)
}
return l1 to l2 app l3
}
data class Tuple4<T1, T2, T3, T4>(val t1: T1, val t2: T2, val t3: T3, val t4: T4)
infix fun <T1, T2, T3, T4> Triple<T1, T2, T3>.app(t4: T4) = Tuple4(first, second, third, t4)
fun <T1, T2, T3, T4> List<Tuple4<T1, T2, T3, T4>>.unzip(): Tuple4<List<T1>, List<T2>, List<T3>, List<T4>> {
val l1 = ArrayList<T1>(size)
val l2 = ArrayList<T2>(size)
val l3 = ArrayList<T3>(size)
val l4 = ArrayList<T4>(size)
for((t1, t2, t3, t4) in this) {
l1.add(t1)
l2.add(t2)
l3.add(t3)
l4.add(t4)
}
return l1 to l2 app l3 app l4
}
@@ -25,7 +25,7 @@ interface IProjectRepo {
suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse>
suspend fun updateVersion(id: Uuid, name: String? = null, color: Color? = null, meta: String? = null): Either<ErrorResponse, Unit>
suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit>
suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit>
suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
unit: TimeUnit, input: String, hardware: String): Either<ErrorResponse, EntryResponse>
@@ -48,7 +48,7 @@ interface IProjectRepo {
override suspend fun updateVersion(id: Uuid, name: String?, color: Color?, meta: String?): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Version.update, id to PartialVersionRequest(name, color?.hexString(), meta)).ignoreValue()
override suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit> =
override suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Version.delete, id).ignoreValue()
override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>,
@@ -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
}
@@ -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
@@ -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<String>() }
val enabledHardware = remember { mutableStateSetOf<String>() }
var filterInput by remember { mutableStateOf<String?>(null) }
var filterHardware by remember { mutableStateOf<String?>(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<String>() }
val enabledHardware = remember { mutableStateSetOf<String>() }
var filterInput by remember { mutableStateOf<String?>(null) }
var filterHardware by remember { mutableStateOf<String?>(null) }
LaunchedEffect(entries, displayWarmups, currentUnit, timeFilter, timeMin, timeMax, labelVersionFilter, filterInput, filterHardware) {
LaunchedEffect(filteredEntries, displayWarmups, currentUnit) {
var maxX = 0f
var minY = 0f
var maxY = 0f
val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
enabledInputs.clear()
enabledHardware.clear()
renderableEntries = entries.mapNotNull { entry ->
renderableEntries = filteredEntries.map { entry ->
val use = if(displayWarmups) entry.warmups else entry.measurements
if(entry.label !in versionFilter) return@mapNotNull null
if(!(entry.timeStamp inRange timeRange)) return@mapNotNull null
if(filterInput != null && entry.input != filterInput) return@mapNotNull null
if(filterHardware != null && entry.hardware != filterHardware) return@mapNotNull null
maxX = maxOf(maxX, use.size.toFloat())
enabledInputs.add(entry.input)
enabledHardware.add(entry.hardware)
use.mapIndexed { idx, it ->
val converted = entry.nativeUnit.convertTo(currentUnit, it)
minY = minOf(minY, converted)
@@ -451,6 +442,12 @@ fun ProjectPlotArea(vm: ProjectVM){
yMax = maxY
}
var deletingVersion by remember { mutableStateOf<ProjectVM.Version?>(null) }
var baseline by remember { mutableStateOf<ProjectVM.Version?>(null) }
var baselineComparison by remember { mutableStateOf<List<Tuple4<String, Float, Color, Float>>?>(null) }
var baselineMax by remember { mutableStateOf(2.0f) }
LaunchedEffect(entries) {
timeMin = entries.minOfOrNull { it.timeStamp } ?: Instant.DISTANT_PAST
timeMax = entries.maxOfOrNull { it.timeStamp } ?: Instant.DISTANT_FUTURE
@@ -460,8 +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<ProjectVM.Version, Pair<Float, Float>>()
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 ?: "<unknown version>"}", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(10.dp))
items((allInputs intersect enabledInputs.toSet()).toList()) {
Row(Modifier.fillMaxWidth().clickable { filterInput = if(filterInput == null) it else null }) {
Text(it, Modifier.padding(start = 5.dp))
val (xs, ys, colors) = blc.unzip()
XYGraph(
xAxisModel = remember(blc) { CategoryAxisModel(xs) },
yAxisModel = rememberFloatLinearAxisModel(0f..baselineMax),
xAxisContent = AxisContent(
labels = { AxisLabel(it) },
title = {},
style = style
),
yAxisContent = AxisContent(
labels = { AxisLabel(format(it)) },
title = {},
style = style
),
modifier = Modifier.weight(1f),
gridStyle = gridStyle
) {
VerticalBarPlot(
xData = xs,
yData = ys,
bar = { x, _, _ -> DefaultBar(brush = SolidColor(colors[x]), modifier = Modifier.fillMaxWidth()) }
)
blc.forEach { (x, y, _, time) ->
XYAnnotation(Point(x, y), AnchorPoint.TopCenter) {
Text("${format(y)}x\n${format(time)} ${currentUnit.disp}", Modifier.padding(top = 5.dp))
}
}
}
}
}
}
}
items((allInputs - enabledInputs.toSet()).toList()) {
Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f))
}
deletingVersion?.let { rmV ->
ConfirmDeleteVersionDialog(rmV, { deletingVersion = null }) {
vm.deleteVersion(rmV.id)
}
}
}
item { Spacer(Modifier.height(10.dp)) }
item { HorizontalDivider(Modifier.height(1.dp)) }
item { Spacer(Modifier.height(10.dp)) }
item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) }
item {
Text("Click an item to set or clear the filter.", fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall)
}
item { Spacer(Modifier.height(10.dp)) }
items((allHardware intersect enabledHardware.toSet()).toList()) {
Row(Modifier.fillMaxWidth().clickable { filterHardware = if(filterHardware == null) it else null }) {
Text(it, Modifier.padding(start = 5.dp))
}
}
items((allHardware - enabledHardware.toSet()).toList()) {
Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f))
}
@Composable
fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit, onDelete: () -> Unit) {
Dialog(onDismissRequest = onCancel) {
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) {
Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(10.dp))
Text("Are you sure you want to delete $version?")
Text("This will also remove all data associated with this version.")
Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
}
}
}
@@ -1,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))
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)
@@ -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)
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)
}
+3 -1
View File
@@ -22,6 +22,7 @@ koala-plot = "0.12.0"
kolor-picker = "2.1.0"
shadow = "9.3.0"
clikt = "5.0.3"
buildconfig = "6.0.10"
[libraries]
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
@@ -94,4 +95,5 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref =
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
buildconfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildconfig" }
+28
View File
@@ -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<Task>("serverVersion") {
doFirst {
val outFile = versionDirectory.get().file("com/jaytux/phoebench/server/Version.kt").asFile
outFile.parentFile.mkdirs()
outFile.writeText("""
package com.jaytux.phoebench.server
object ServerVersion {
const val VERSION="${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 {
@@ -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)
@@ -0,0 +1 @@
org.slf4j.simpleLogger.log.io.ktor.server.plugins.cors=TRACE