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() mavenCentral()
} }
version = "1.0.0-dev" version = "1.0.0"
+28 -1
View File
@@ -8,7 +8,22 @@ plugins {
} }
group = "com.jaytux.phoebench" 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 { dependencies {
implementation(kotlin("stdlib")) implementation(kotlin("stdlib"))
@@ -42,6 +57,18 @@ kotlin {
freeCompilerArgs.add("-Xcontext-parameters") freeCompilerArgs.add("-Xcontext-parameters")
optIn.add("kotlin.uuid.ExperimentalUuidApi") optIn.add("kotlin.uuid.ExperimentalUuidApi")
} }
sourceSets {
val main by getting {
kotlin {
srcDir(versionDirectory)
}
}
}
tasks.compileKotlin {
dependsOn(generateVersion)
}
} }
tasks.withType<ShadowJar> { 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.options.*
import com.github.ajalt.clikt.parameters.types.float import com.github.ajalt.clikt.parameters.types.float
import com.github.ajalt.clikt.parameters.types.inputStream import com.github.ajalt.clikt.parameters.types.inputStream
import com.jaytux.phoebench.client.cli.CliVersion
import com.jaytux.phoebench.common.TimeUnit import com.jaytux.phoebench.common.TimeUnit
import io.ktor.util.reflect.* import io.ktor.util.reflect.*
import io.ktor.utils.io.* import io.ktor.utils.io.*
@@ -23,6 +24,10 @@ object CLI {
help = "Disable all interactive input (makes arguments marked with (*) mandatory)" help = "Disable all interactive input (makes arguments marked with (*) mandatory)"
).flag(default = false) ).flag(default = false)
init {
versionOption(CliVersion.VERSION)
}
override fun run() { override fun run() {
val sub = currentContext.invokedSubcommand val sub = currentContext.invokedSubcommand
if(sub == null) { if(sub == null) {
@@ -208,6 +213,13 @@ object CLI {
override fun run() = ProjectHandlers.newData(parent.finder, version, warmup, measurement, unit, input, hardware) 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>) { fun main(args: Array<String>) {
@@ -80,9 +80,10 @@ object Client {
val res = route.call(client, body) val res = route.call(client, body)
res res
} catch (e: CancellationException) { } catch (e: CancellationException) {
println("Coroutine calling ${route.verb} ${route.pattern} was cancelled.")
ErrorResponse("Coroutine calling ${route.verb} ${route.pattern} was cancelled.").error() ErrorResponse("Coroutine calling ${route.verb} ${route.pattern} was cancelled.").error()
} catch (e: Exception) { } 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() ErrorResponse(e.message ?: "Unknown error.").error()
} }
} }
@@ -1,7 +1,10 @@
package com.jaytux.phoebench.clients.cli package com.jaytux.phoebench.clients.cli
import kotlin.system.exitProcess
fun main(args: Array<String>) { fun main(args: Array<String>) {
Client.forceGloballyInitialized() Client.forceGloballyInitialized()
CLI.main(args) CLI.main(args)
Client.shutdown() Client.shutdown()
exitProcess(0) // because D-Bus threads...
} }
@@ -1,14 +1,63 @@
package com.jaytux.phoebench.clients.cli package com.jaytux.phoebench.clients.cli
import com.github.javakeyring.BackendNotSupportedException
import com.github.javakeyring.Keyring import com.github.javakeyring.Keyring
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import java.util.prefs.Preferences
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
object PersistentStorage { object PersistentStorage {
private val json = Json private val json = Json
const val SERVICE = "com.jaytux.phoebench.cli" 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>( class StoredProperty<T>(
private val _key: String, private val _key: String,
@@ -20,21 +69,15 @@ object PersistentStorage {
) )
fun load(): T? = runCatching { fun load(): T? = runCatching {
Keyring.create().use { _backend.read(_key)
it.getPassword(SERVICE, _key)
}
}.getOrNull()?.let { _fromString(it) } }.getOrNull()?.let { _fromString(it) }
fun save(value: T) = runCatching { fun save(value: T) = runCatching {
val keyring = Keyring.create().use { _backend.write(_key, _toString(value))
it.setPassword(SERVICE, _key, _toString(value))
}
}.onFailure { println("Failed to write to OS keyring: ${it.message}") }.ignore() }.onFailure { println("Failed to write to OS keyring: ${it.message}") }.ignore()
fun erase() = runCatching { fun erase() = runCatching {
val keyring = Keyring.create().use { _backend.erase(_key)
it.deletePassword(SERVICE, _key)
}
}.ignore() }.ignore()
} }
@@ -67,15 +67,15 @@ object ProjectHandlers {
System.err.println("Failed to get project details: ${it.msg}") System.err.println("Failed to get project details: ${it.msg}")
}) { }) {
println("Project ${it.owner.name}/${it.name} [${it.id}]:") println("Project ${it.owner.name}/${it.name} [${it.id}]:")
println("${it.usedLabels.size} labels:") println("${it.usedLabels.size} versions:")
it.usedLabels.forEach { l -> it.usedLabels.forEach { v ->
println(" - [${l.id}] ${l.name} (with color ${l.color})") println(" - [${v.id}] VERSION ${v.name} (with color ${v.color}); metadata: ${v.meta}")
} }
val map = it.usedLabels.associateBy { l -> l.id } val map = it.usedLabels.associateBy { l -> l.id }
println("\n${it.entries.size} entries:") println("\n${it.entries.size} entries:")
it.entries.forEach { e -> 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})") "(${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?) { fun newVersion(name: String?, color: String?, project: CLI.Commands.Project.IProjectIdentification?, version: String?) {
val id = ensureProjectIdentification(project) val id = ensureProjectIdentification(project)
val useName = name.maybePrompt("label name") { it } val useName = name.maybePrompt("version name") { it }
val useColor = color.maybePrompt("color") { val useColor = color.maybePrompt("color") {
val check = it.length == 7 && it[0] == '#' && it.substring(1, it.length).all { c -> c.isDigit() || c in "ABCDEF" } val check = it.length == 7 && it[0] == '#' && it.substring(1, it.length).all { c -> c.isDigit() || c in "ABCDEF" }
if(check) it if(check) it
@@ -110,7 +110,7 @@ object ProjectHandlers {
Client.callRoute(Routes.Version.new, VersionRequest(useName, useColor, useVersion, projectId)) Client.callRoute(Routes.Version.new, VersionRequest(useName, useColor, useVersion, projectId))
} }
}.fold({ }.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.") 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.ID -> versionId.id.value()
is CLI.Commands.Project.VersionName -> Client.callRoute(Routes.Project.get, pId).bind { is CLI.Commands.Project.VersionName -> Client.callRoute(Routes.Project.get, pId).bind {
it.usedLabels.firstOrNull { l -> l.name == versionId.name }?.id?.value() 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 } }.map { pId to it }
}.bind { (pId, vId) -> }.bind { (pId, vId) ->
+62 -1
View File
@@ -10,6 +10,24 @@ plugins {
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.serialization) 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 { kotlin {
@@ -86,6 +104,20 @@ kotlin {
wasmJsMain.dependencies { wasmJsMain.dependencies {
implementation(libs.ktor.client.js) implementation(libs.ktor.client.js)
} }
commonMain {
kotlin {
srcDir(versionDirectory)
}
}
}
targets.all {
compilations.all {
compileTaskProvider.configure {
dependsOn(generateVersion)
}
}
} }
} }
@@ -96,7 +128,7 @@ compose.desktop {
nativeDistributions { nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.jaytux.phoebench.clients" packageName = "com.jaytux.phoebench.clients"
packageVersion = rootProject.version.toString().split('-')[0] packageVersion = version.toString()
jvmArgs += listOf("--enable-native-access=ALL-UNNAMED") jvmArgs += listOf("--enable-native-access=ALL-UNNAMED")
includeAllModules = true includeAllModules = true
@@ -106,3 +138,32 @@ 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 package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig import io.ktor.client.HttpClientConfig
@@ -21,3 +22,8 @@ expect fun persistentStore(): IStore
expect fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient 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,13 +77,6 @@ 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 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> { inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T) -> X): List<T> {
val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy) val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy)
if(insertionPoint >= 0) return this if(insertionPoint >= 0) return this
@@ -91,3 +84,42 @@ inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T
return toMutableList().apply { add(index, elem) } return toMutableList().apply { add(index, elem) }
} }
fun List<Float>.geomean(): Float {
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 newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse>
suspend fun updateVersion(id: Uuid, name: String? = null, color: Color? = null, meta: String? = null): Either<ErrorResponse, Unit> suspend fun updateVersion(id: Uuid, name: String? = null, color: Color? = null, meta: String? = null): Either<ErrorResponse, Unit>
suspend fun 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>, suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
unit: TimeUnit, input: String, hardware: String): Either<ErrorResponse, EntryResponse> unit: TimeUnit, input: String, hardware: String): Either<ErrorResponse, EntryResponse>
@@ -48,7 +48,7 @@ interface IProjectRepo {
override suspend fun updateVersion(id: Uuid, name: String?, color: Color?, meta: String?): Either<ErrorResponse, Unit> = 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() _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() _client.callRoute(Routes.Version.delete, id).ignoreValue()
override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, 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 { withScope {
_repo.deleteLabel(id).snackOr { _repo.deleteVersion(id).snackOr {
if(_job == null) if(_job == null)
_labels.value -= id _labels.value -= id
} }
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items 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.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@@ -49,7 +45,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.backhandler.BackHandler
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -60,7 +55,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.composables.icons.lucide.ChevronLeft import com.composables.icons.lucide.ChevronLeft
import com.composables.icons.lucide.Delete
import com.composables.icons.lucide.LogOut import com.composables.icons.lucide.LogOut
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Pencil 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.ShieldPlus
import com.composables.icons.lucide.Trash import com.composables.icons.lucide.Trash
import com.composables.icons.lucide.X import com.composables.icons.lucide.X
import com.jaytux.phoebench.client.ClientVersion
import com.jaytux.phoebench.clients.AuthProvider import com.jaytux.phoebench.clients.AuthProvider
import com.jaytux.phoebench.clients.Client import com.jaytux.phoebench.clients.Client
import com.jaytux.phoebench.clients.ServerSelect
import com.jaytux.phoebench.clients.SnackProvider import com.jaytux.phoebench.clients.SnackProvider
import com.jaytux.phoebench.clients.data.HomeVM import com.jaytux.phoebench.clients.data.HomeVM
import com.jaytux.phoebench.clients.data.mutableStateSetOf 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.LoginRequest
import com.jaytux.phoebench.common.NamedID import com.jaytux.phoebench.common.NamedID
import com.jaytux.phoebench.common.ProtocolVersion import com.jaytux.phoebench.common.ProtocolVersion
import com.jaytux.phoebench.common.RefreshRequest
import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.SignupRequest import com.jaytux.phoebench.common.SignupRequest
import com.jaytux.phoebench.common.UserListResponse import com.jaytux.phoebench.common.UserListResponse
import com.jaytux.phoebench.common.fold import com.jaytux.phoebench.common.fold
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@Composable @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 @Composable
fun LoginView(server: String?, error: String?, onSwitchSignup: () -> Unit, onLogin: (server: String, user: String, pass: String) -> Unit) { fun LoginView(server: String?, error: String?, onSwitchSignup: () -> Unit, onLogin: (server: String, user: String, pass: String) -> Unit) {
var serverUrl by remember { mutableStateOf(server ?: "https://") } 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) { Column(Modifier.padding(5.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Log in", style = MaterialTheme.typography.headlineMedium) 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(username, { username = it }, Modifier.fillMaxWidth(), label = { Text("Username") }, singleLine = true)
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") }, visualTransformation = PasswordVisualTransformation(), singleLine = true) OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") }, visualTransformation = PasswordVisualTransformation(), singleLine = true)
@@ -250,7 +255,7 @@ fun AuthenticatedRoot() {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("PhoeBench", style = MaterialTheme.typography.headlineLarge) }, title = { Text("PhoeBench v${ClientVersion.VERSION}", style = MaterialTheme.typography.headlineLarge) },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
@@ -25,33 +25,18 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.composables.icons.lucide.* import com.composables.icons.lucide.*
import com.jaytux.phoebench.clients.*
import com.jaytux.phoebench.clients.data.ProjectVM import com.jaytux.phoebench.clients.data.ProjectVM
import com.jaytux.phoebench.clients.data.mutableStateSetFrom import com.jaytux.phoebench.clients.data.mutableStateSetFrom
import com.jaytux.phoebench.clients.data.mutableStateSetOf import com.jaytux.phoebench.clients.data.mutableStateSetOf
import com.jaytux.phoebench.clients.dualLerp import com.jaytux.phoebench.common.*
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.kborowy.colorpicker.KolorPicker 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.line.LinePlot
import io.github.koalaplot.core.style.KoalaPlotTheme import io.github.koalaplot.core.style.KoalaPlotTheme
import io.github.koalaplot.core.style.LineStyle import io.github.koalaplot.core.style.LineStyle
import io.github.koalaplot.core.xygraph.AxisContent import io.github.koalaplot.core.xygraph.*
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 kotlin.time.Instant import kotlin.time.Instant
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -248,8 +233,11 @@ fun QuickVersion(lbl: ProjectVM.Version, hollow: Boolean = false) {
color = LocalContentColor.current.copy(alpha = 0.75f), color = LocalContentColor.current.copy(alpha = 0.75f),
) )
} }
Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, if(lbl.meta.isNotBlank())
color = LocalContentColor.current.copy(0.75f)) 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 entries by vm.entries
val versions by vm.versions val versions by vm.versions
val editable by vm.editable val editable by vm.editable
val allInputs by vm.inputs
val 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 displayWarmups by remember { mutableStateOf(false) }
var currentUnit by remember { mutableStateOf(TimeUnit.SECONDS) } var currentUnit by remember { mutableStateOf(TimeUnit.SECONDS) }
@@ -402,42 +421,14 @@ fun ProjectPlotArea(vm: ProjectVM){
var yMin by remember { mutableStateOf(0f) } var yMin by remember { mutableStateOf(0f) }
var yMax by remember { mutableStateOf(1f) } var yMax by remember { mutableStateOf(1f) }
var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) } LaunchedEffect(filteredEntries, displayWarmups, currentUnit) {
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) {
var maxX = 0f var maxX = 0f
var minY = 0f var minY = 0f
var maxY = 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 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()) maxX = maxOf(maxX, use.size.toFloat())
enabledInputs.add(entry.input)
enabledHardware.add(entry.hardware)
use.mapIndexed { idx, it -> use.mapIndexed { idx, it ->
val converted = entry.nativeUnit.convertTo(currentUnit, it) val converted = entry.nativeUnit.convertTo(currentUnit, it)
minY = minOf(minY, converted) minY = minOf(minY, converted)
@@ -451,6 +442,12 @@ fun ProjectPlotArea(vm: ProjectVM){
yMax = maxY 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) { LaunchedEffect(entries) {
timeMin = entries.minOfOrNull { it.timeStamp } ?: Instant.DISTANT_PAST timeMin = entries.minOfOrNull { it.timeStamp } ?: Instant.DISTANT_PAST
timeMax = entries.maxOfOrNull { it.timeStamp } ?: Instant.DISTANT_FUTURE 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() 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)) { Row(Modifier.fillMaxSize().padding(20.dp)) {
Box(Modifier.weight(0.66f).fillMaxHeight()) { Row(Modifier.weight(0.66f).fillMaxHeight()) {
if(entries.isEmpty()) { if(entries.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
@@ -518,24 +546,16 @@ fun ProjectPlotArea(vm: ProjectVM){
} }
Spacer(Modifier.height(15.dp)) 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( XYGraph(
rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()), rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()),
rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()), rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()),
xAxisContent = AxisContent( xAxisContent = AxisContent(
labels = { AxisLabel(it.fmt()) }, labels = { AxisLabel(format(it)) },
title = {}, title = {},
style = style style = style
), ),
yAxisContent = AxisContent( yAxisContent = AxisContent(
labels = { AxisLabel(it.fmt()) }, labels = { AxisLabel(format(it)) },
title = {}, title = {},
style = style style = style
), ),
@@ -552,40 +572,57 @@ fun ProjectPlotArea(vm: ProjectVM){
Spacer(Modifier.width(20.dp)) Spacer(Modifier.width(20.dp))
Row(Modifier.weight(0.33f).fillMaxHeight()) { Column(Modifier.weight(0.33f).fillMaxHeight()) {
Column(Modifier.weight(0.5f)) { Row {
Text("Versions", style = MaterialTheme.typography.headlineSmall) Column(Modifier.weight(0.5f)) {
Text("Click a version to toggle its visibility.", fontStyle = FontStyle.Italic, Text("Versions", style = MaterialTheme.typography.headlineSmall)
color = LocalContentColor.current.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall) Text("Click a version to toggle its visibility.", fontStyle = FontStyle.Italic,
Spacer(Modifier.height(10.dp)) color = LocalContentColor.current.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall)
if(versions.isEmpty()) { Spacer(Modifier.height(10.dp))
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { if(versions.isEmpty()) {
Text("No versions yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
Text("No versions yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
}
} }
} else {
else { LazyColumn(Modifier.padding(start = 5.dp)) {
LazyColumn(Modifier.padding(start = 5.dp)) { items(versions.toList().sortedBy { it.second.name }) { (_, lbl) ->
items(versions.toList().sortedBy { it.second.name }) { (_, lbl) -> Box(Modifier.fillMaxWidth().clickable {
Box(Modifier.fillMaxWidth().clickable { baseline = null
if(versionFilter.size == versions.size) { if(versionFilter.size == versions.size) {
versionFilter.clear() versionFilter.clear()
versionFilter.add(lbl) versionFilter.add(lbl)
} }
else { else {
versionFilter.toggle(lbl) versionFilter.toggle(lbl)
if(versionFilter.isEmpty()) versionFilter.addAll(versions.values) if(versionFilter.isEmpty()) versionFilter.addAll(versions.values)
} }
}) { }) {
Box { Row {
QuickVersion(lbl, lbl !in versionFilter) Box(Modifier.weight(1f)) {
QuickVersion(lbl, lbl !in versionFilter)
if(lbl !in versionFilter) { if (lbl !in versionFilter) {
Box(Modifier.matchParentSize()) { Box(Modifier.matchParentSize()) {
HorizontalDivider( HorizontalDivider(
Modifier.fillMaxWidth().align(Alignment.Center), Modifier.fillMaxWidth().align(Alignment.Center),
thickness = 3.dp, thickness = 3.dp,
color = LocalContentColor.current 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()) { val (xs, ys, colors) = blc.unzip()
Row(Modifier.fillMaxWidth().clickable { filterInput = if(filterInput == null) it else null }) {
Text(it, Modifier.padding(start = 5.dp)) 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()) { deletingVersion?.let { rmV ->
Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f)) ConfirmDeleteVersionDialog(rmV, { deletingVersion = null }) {
} vm.deleteVersion(rmV.id)
}
}
}
item { Spacer(Modifier.height(10.dp)) } @Composable
item { HorizontalDivider(Modifier.height(1.dp)) } fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit, onDelete: () -> Unit) {
item { Spacer(Modifier.height(10.dp)) } Dialog(onDismissRequest = onCancel) {
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) } Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) {
item { Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
Text("Click an item to set or clear the filter.", fontStyle = FontStyle.Italic, Spacer(Modifier.height(10.dp))
color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall) 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))
item { Spacer(Modifier.height(10.dp)) } CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
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))
}
} }
} }
} }
@@ -1,8 +1,10 @@
package com.jaytux.phoebench.clients package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import com.github.javakeyring.Keyring import com.github.javakeyring.Keyring
import com.jaytux.phoebench.clients.ui.DefaultServerSelect
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.okhttp.* import io.ktor.client.engine.okhttp.*
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
@@ -56,3 +58,8 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
actual suspend fun String.toClipEntry(): ClipEntry = 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 package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import com.jaytux.phoebench.clients.ui.constantServerSelect
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.js.* import io.ktor.client.engine.js.*
import kotlinx.browser.window import kotlinx.browser.window
@@ -47,3 +49,14 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
actual suspend fun String.toClipEntry(): ClipEntry = 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)
}
+2
View File
@@ -22,6 +22,7 @@ koala-plot = "0.12.0"
kolor-picker = "2.1.0" kolor-picker = "2.1.0"
shadow = "9.3.0" shadow = "9.3.0"
clikt = "5.0.3" clikt = "5.0.3"
buildconfig = "6.0.10"
[libraries] [libraries]
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
@@ -95,3 +96,4 @@ serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 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" group = "com.jaytux.phoebench"
version = rootProject.version
repositories { repositories {
mavenCentral() 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 { dependencies {
implementation(libs.exposed.core) implementation(libs.exposed.core)
implementation(libs.exposed.dao) implementation(libs.exposed.dao)
@@ -63,6 +79,18 @@ kotlin {
freeCompilerArgs.add("-Xcontext-parameters") freeCompilerArgs.add("-Xcontext-parameters")
optIn.add("kotlin.uuid.ExperimentalUuidApi") optIn.add("kotlin.uuid.ExperimentalUuidApi")
} }
sourceSets {
val main by getting {
kotlin {
srcDir(versionDirectory)
}
}
}
tasks.compileKotlin {
dependsOn(generateVersion)
}
} }
application { application {
@@ -25,24 +25,14 @@ import kotlin.uuid.Uuid
fun Application.module() { fun Application.module() {
DB.db DB.db
println("Entering module...") println("Entering module for PhoeBench Server version ${ServerVersion.VERSION}...")
install(ContentNegotiation) { install(ContentNegotiation) {
json() 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 allowLocalhost = environment.config.propertyOrNull("ktor.cors.enableLocalhostOn")?.getString() ?: "0"
val safeOrigin = environment.config.propertyOrNull("ktor.cors.browserOrigin")?.getString() val safeOrigin = environment.config.propertyOrNull("ktor.cors.browserOrigin")?.getString()
@@ -52,6 +42,7 @@ fun Application.module() {
allowMethod(HttpMethod.Patch) allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Get) allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post) allowMethod(HttpMethod.Post)
allowHeader(HttpHeaders.Accept)
allowHeader(HttpHeaders.Authorization) allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.ContentType) allowHeader(HttpHeaders.ContentType)
exposeHeader(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(AutoHeadResponse)
install(CallLogging) install(CallLogging)
@@ -0,0 +1 @@
org.slf4j.simpleLogger.log.io.ktor.server.plugins.cors=TRACE