Compare commits
5
Commits
v1.0.0-dev
..
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc67adad3
|
||
|
|
8928446444
|
||
|
|
223ec3b886
|
||
|
|
07225ae461
|
||
|
|
462c6da965
|
+3
-1
@@ -47,4 +47,6 @@ bin/
|
|||||||
*.db
|
*.db
|
||||||
.env
|
.env
|
||||||
.idea/
|
.idea/
|
||||||
artifacts/
|
artifacts/
|
||||||
|
|
||||||
|
clients/compose/src/wasmJsMain/resources/config.json
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# PhoeBench
|
||||||
|
*Introduction to be done*
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
### Server
|
||||||
|
The server ZIP files in the releases contain all dependencies, so just unzip it where you want to run the server.
|
||||||
|
|
||||||
|
First, set up the environment variables (this cannot currently be done using `.env` files, I think):
|
||||||
|
- `JWT_AUDIENCE`: the audience to use for the JSON Web Tokens authentication, typically set to your URL;
|
||||||
|
- `JWT_ISSUER`: the issuer for the authentication tokens, typically also set to your URL;
|
||||||
|
- `JWT_REALM`: the realm for which the authentication tokens are valid, doesn't matter as there is only one "realm" (I use `phoebench`);
|
||||||
|
- `JWT_SECRET`: a strong secret key to encrypt the authentication tokens (e.g. generated by `gpg --gen-random --armor 1 128`);
|
||||||
|
- `PORT`: the port to host the server on;
|
||||||
|
- `PHOEBENCH_SAFE_CLIENT`: (optional) if you plan on hosting a web client, set this to its URL (for CORS requests);
|
||||||
|
- `PHOEBENCH_DEV_CLIENT_PORT`: (development only) port to enable CORS requests from `localhost` and `127.0.0.1`.
|
||||||
|
|
||||||
|
Next, set up the database variables (recommended through a `.env` file):
|
||||||
|
- `DATABASE_URL`: JDBC URL to connect to the database;
|
||||||
|
- `DATABASE_DRIVER`: fully-qualified name of the JDBC driver to connect to the database with;
|
||||||
|
- `DATABASE_USER`: (if needed) username for the database;
|
||||||
|
- `DATABASE_PASSWORD`: (if needed) password for the database.
|
||||||
|
|
||||||
|
The server currently supports SQLite and MariaDB as databases, using the following settings:
|
||||||
|
```
|
||||||
|
# SQLite
|
||||||
|
DATABASE_URL=jdbc:sqlite:/path/to/db/file.db
|
||||||
|
DATABASE_DRIVER=org.sqlite.JDBC
|
||||||
|
# SQLite does not need user/password
|
||||||
|
|
||||||
|
# MariaDB
|
||||||
|
DATABASE_URL=jdbc:mariadb://host:port/database-name
|
||||||
|
DATABASE_DRIVER=org.mariadb.jdbc.Driver
|
||||||
|
DATABASE_USER=username
|
||||||
|
DATABASE_PASSWORD=pass
|
||||||
|
```
|
||||||
|
For SQLite, don't forget to substitute `/path/to/db/file.db` (with the path to where the database should be stored).
|
||||||
|
For MariaDB, don't forget to substitute `host`, `port`, `database-name`, `username`, and `pass` (`host:port` will typically be `localhost:3306`).
|
||||||
|
To use MariaDB, you will need to create the database and user through its administration interface.
|
||||||
|
|
||||||
|
### JVM Client
|
||||||
|
The JVM client does not require any additional setup.
|
||||||
|
Simply unzip the client ZIP for your platform (currently only built for Linux), and run `bin/com.jaytux.phoebench.clients` from the unzip location.
|
||||||
|
|
||||||
|
However, to avoid having to log in again every time you start the app, it is recommended to ensure you have a working keyring set up.
|
||||||
|
|
||||||
|
### WASM Client
|
||||||
|
The WASM/JS client ZIP contains files that can be statically served, including all dependencies.
|
||||||
|
As the WASM files can take some time to load over the network, it is recommended to modify the `index.html` file to contain some sort of loading screen.
|
||||||
|
|
||||||
|
As for setup - you need to create a file called `config.json` with the following content:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": "server url"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Replace `server url` with the URL of the server to connect to (don't forget to add it as `PHOEBENCH_SAFE_CLIENT` to the server, see above).
|
||||||
|
For deployment, this file should be in the same directory where the ZIP was extracted.
|
||||||
|
For development, it should be under `/clients/compose/src/wasmJsMain/resources/` to ensure correct loading.
|
||||||
|
|
||||||
|
### CLI Client
|
||||||
|
The CLI client does not require any additional setup, simply use Java to run the JAR: `java -jar phoebench-cli.jar`.
|
||||||
|
To use the CLI client in scripts, it is recommended to suppress certain warnings: `java --enable-native-access=ALL_UNNAMED -jar phoebench-cli.jar`, as it will otherwise output warning messages that might trip up your scripts.
|
||||||
+1
-1
@@ -12,4 +12,4 @@ repositories {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
version = "1.0.0-dev"
|
version = "1.0.0"
|
||||||
@@ -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="$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) ->
|
||||||
|
|||||||
@@ -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="$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
|
||||||
@@ -105,4 +137,21 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -20,4 +21,9 @@ 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,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
|
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
|
||||||
val index = -insertionPoint - 1
|
val index = -insertionPoint - 1
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
+2
-2
@@ -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>,
|
||||||
|
|||||||
+2
-2
@@ -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,7 @@ 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(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)
|
||||||
@@ -208,7 +212,7 @@ fun SignupView(server: String?, error: String?, onSwitchLogin: () -> Unit, onSig
|
|||||||
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("Sign up", style = MaterialTheme.typography.headlineMedium)
|
Text("Sign up", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
|
||||||
OutlinedTextField(serverUrl, { serverUrl = it }, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true)
|
ServerSelect(serverUrl) { serverUrl = it }
|
||||||
|
|
||||||
OutlinedTextField(inviteCode, { inviteCode = it }, Modifier.fillMaxWidth(), label = { Text("Invite code") }, singleLine = true)
|
OutlinedTextField(inviteCode, { inviteCode = it }, Modifier.fillMaxWidth(), label = { Text("Invite code") }, singleLine = true)
|
||||||
OutlinedTextField(username, { username = it }, Modifier.fillMaxWidth(), label = { Text("Username") }, singleLine = true)
|
OutlinedTextField(username, { username = it }, Modifier.fillMaxWidth(), label = { Text("Username") }, singleLine = true)
|
||||||
@@ -250,7 +254,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
|
||||||
|
|||||||
+271
-167
@@ -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
|
||||||
|
|
||||||
@@ -92,47 +77,49 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
|
|||||||
Box(Modifier.fillMaxSize().padding(15.dp)) {
|
Box(Modifier.fillMaxSize().padding(15.dp)) {
|
||||||
ProjectPlotArea(vm)
|
ProjectPlotArea(vm)
|
||||||
|
|
||||||
if(addOpen) {
|
if(editable) {
|
||||||
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
|
|
||||||
Box(Modifier.clickable { addOpen = false }.fillMaxSize())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.align(Alignment.BottomEnd).padding(25.dp), horizontalAlignment = Alignment.End) {
|
|
||||||
if(addOpen) {
|
if(addOpen) {
|
||||||
var lblWidth by mutableStateOf(1000.dp)
|
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
|
||||||
var dataWidth by mutableStateOf(1000.dp)
|
Box(Modifier.clickable { addOpen = false }.fillMaxSize())
|
||||||
val density = LocalDensity.current
|
|
||||||
|
|
||||||
Column(Modifier.width(maxOf(lblWidth, dataWidth)), horizontalAlignment = Alignment.End) {
|
|
||||||
Surface(Modifier.onGloballyPositioned {
|
|
||||||
lblWidth = with(density) { it.size.width.toDp() }
|
|
||||||
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
|
|
||||||
Row(Modifier.clickable { addingLabel = true }.padding(15.dp)) {
|
|
||||||
Icon(Lucide.CaseSensitive, "Add label")
|
|
||||||
Spacer(Modifier.width(5.dp))
|
|
||||||
Text("Label")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(10.dp))
|
|
||||||
|
|
||||||
Surface(Modifier.onGloballyPositioned {
|
|
||||||
dataWidth = with(density) { it.size.width.toDp() }
|
|
||||||
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
|
|
||||||
Row(Modifier.clickable { addingData = true }.padding(15.dp)) {
|
|
||||||
Icon(Lucide.ChartLine, "Add data")
|
|
||||||
Spacer(Modifier.width(5.dp))
|
|
||||||
Text("Data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(15.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton({ addOpen = !addOpen }, Modifier.scale(1.5f), shape = MaterialTheme.shapes.medium) {
|
Column(Modifier.align(Alignment.BottomEnd).padding(25.dp), horizontalAlignment = Alignment.End) {
|
||||||
Icon(if(addOpen) Lucide.X else Lucide.Plus, if(addOpen) "Add data" else "Close menu")
|
if (addOpen) {
|
||||||
|
var lblWidth by mutableStateOf(1000.dp)
|
||||||
|
var dataWidth by mutableStateOf(1000.dp)
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
Column(Modifier.width(maxOf(lblWidth, dataWidth)), horizontalAlignment = Alignment.End) {
|
||||||
|
Surface(Modifier.onGloballyPositioned {
|
||||||
|
lblWidth = with(density) { it.size.width.toDp() }
|
||||||
|
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
|
||||||
|
Row(Modifier.clickable { addingLabel = true }.padding(15.dp)) {
|
||||||
|
Icon(Lucide.CaseSensitive, "Add label")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("Label")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Surface(Modifier.onGloballyPositioned {
|
||||||
|
dataWidth = with(density) { it.size.width.toDp() }
|
||||||
|
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
|
||||||
|
Row(Modifier.clickable { addingData = true }.padding(15.dp)) {
|
||||||
|
Icon(Lucide.ChartLine, "Add data")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("Data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(15.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton({ addOpen = !addOpen }, Modifier.scale(1.5f), shape = MaterialTheme.shapes.medium) {
|
||||||
|
Icon(if (addOpen) Lucide.X else Lucide.Plus, if (addOpen) "Add data" else "Close menu")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,8 +235,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 +384,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 +423,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 +444,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 +459,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 +548,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 +574,59 @@ 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")
|
||||||
|
}
|
||||||
|
if(editable) {
|
||||||
|
IconButton({ deletingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) {
|
||||||
|
Icon(Lucide.Trash, "Delete version")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -593,48 +634,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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -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
|
||||||
@@ -55,4 +57,9 @@ 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,11 +1,13 @@
|
|||||||
package com.jaytux.phoebench.clients
|
package com.jaytux.phoebench.clients
|
||||||
|
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.window.ComposeViewport
|
import androidx.compose.ui.window.ComposeViewport
|
||||||
import kotlinx.browser.document
|
import kotlinx.browser.document
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun main() {
|
suspend fun main() {
|
||||||
|
config = loadConfig()
|
||||||
ComposeViewport(document.body!!) {
|
ComposeViewport(document.body!!) {
|
||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-1
@@ -1,10 +1,15 @@
|
|||||||
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
|
||||||
|
import kotlinx.coroutines.await
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.serializer
|
import kotlinx.serialization.serializer
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
@@ -46,4 +51,27 @@ 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())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Config(val server: String)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalWasmJsInterop::class)
|
||||||
|
suspend fun loadConfig(): Config {
|
||||||
|
val resp = window.fetch("config.json").await()
|
||||||
|
val text = resp.text().await()
|
||||||
|
return Json.decodeFromString<Config>(text.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
lateinit var config: Config
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) {
|
||||||
|
constantServerSelect(config.server)(server, onUpdate)
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -94,4 +95,5 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref =
|
|||||||
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
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" }
|
||||||
@@ -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="$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
|
||||||
Reference in New Issue
Block a user