Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
589649d9f6
|
||
|
|
ae7724b3c4
|
||
|
|
ef5ccb9274
|
||
|
|
9e010c7873
|
||
|
|
fce1c097d4
|
||
|
|
b5c4e150a5
|
||
|
|
126a3bfff2
|
||
|
|
563ee98d03
|
||
|
|
7f92df1ca5
|
||
|
|
768bb0c650
|
||
|
|
85902620da
|
||
|
|
8da391b6ce
|
||
|
|
bc9861900e
|
||
|
|
1d10802140
|
||
|
|
62f952ac68
|
||
|
|
c662626bd8
|
||
|
|
e40afb2c3d
|
||
|
|
57586c6600
|
||
|
|
0d3f5ca8ad
|
||
|
|
4fdeafb73b
|
||
|
|
7343300536
|
||
|
|
8dc67adad3
|
||
|
|
8928446444
|
||
|
|
223ec3b886
|
@@ -45,6 +45,11 @@ bin/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
*.db
|
*.db
|
||||||
|
*.bak*
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
*.sql
|
||||||
.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.
|
||||||
+4
-2
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
export PHOEBENCH_BUILD_RELEASE='true'
|
||||||
|
|
||||||
./gradlew :clients:cli:shadowJar
|
./gradlew :clients:cli:shadowJar
|
||||||
./gradlew :clients:compose:createDistributable
|
./gradlew :clients:compose:createDistributable
|
||||||
./gradlew :clients:compose:wasmJsBrowserProductionWebpack
|
./gradlew :clients:compose:wasmJsBrowserProductionWebpack
|
||||||
@@ -14,8 +16,8 @@ cp clients/cli/build/libs/phoebench-cli.jar artifacts/
|
|||||||
cp clients/compose/build/compose/binaries/main/app/com.jaytux.phoebench.clients/phoebench-linux.zip artifacts/
|
cp clients/compose/build/compose/binaries/main/app/com.jaytux.phoebench.clients/phoebench-linux.zip artifacts/
|
||||||
|
|
||||||
mkdir -p artifacts/wasm
|
mkdir -p artifacts/wasm
|
||||||
cp clients/compose/build/kotlin-webpack/wasmJs/productionExecutable/* clients/compose/src/wasmJsMain/resources/* artifacts/wasm
|
cp -r clients/compose/build/kotlin-webpack/wasmJs/productionExecutable/* clients/compose/build/processedResources/wasmJs/main/* artifacts/wasm
|
||||||
(cd artifacts/wasm && zip phoebench-wasm.zip -r ./*)
|
(cd artifacts/wasm && rm config.json && zip phoebench-wasm.zip -r ./*)
|
||||||
mv artifacts/wasm/phoebench-wasm.zip artifacts/
|
mv artifacts/wasm/phoebench-wasm.zip artifacts/
|
||||||
rm -fr artifacts/wasm
|
rm -fr artifacts/wasm
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import PhoebenchVersion
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.composeMultiplatform) apply false
|
alias(libs.plugins.composeMultiplatform) apply false
|
||||||
alias(libs.plugins.composeCompiler) apply false
|
alias(libs.plugins.composeCompiler) apply false
|
||||||
@@ -12,4 +14,4 @@ repositories {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
version = "1.0.0"
|
version = PhoebenchVersion(1, 2, 1, "")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
plugins {
|
||||||
|
`kotlin-dsl`
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import org.gradle.api.Project
|
||||||
|
|
||||||
|
fun Project.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
data class PhoebenchVersion(val major: Int, val minor: Int, val patch: Int, val note: String = "") {
|
||||||
|
operator fun compareTo(other: PhoebenchVersion): Int {
|
||||||
|
// this > other -> return >0
|
||||||
|
val majorComp = major - other.major
|
||||||
|
if(majorComp != 0) return majorComp
|
||||||
|
val minorComp = minor - other.minor
|
||||||
|
if(minorComp != 0) return minorComp
|
||||||
|
return patch - other.patch
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = if(note.isNotBlank()) "$major.$minor.$patch-$note" else safeString()
|
||||||
|
|
||||||
|
fun safeString() = "$major.$minor.$patch"
|
||||||
|
}
|
||||||
@@ -5,10 +5,14 @@ plugins {
|
|||||||
application
|
application
|
||||||
alias(libs.plugins.serialization)
|
alias(libs.plugins.serialization)
|
||||||
alias(libs.plugins.shadow)
|
alias(libs.plugins.shadow)
|
||||||
|
alias(libs.plugins.buildconfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "com.jaytux.phoebench"
|
group = "com.jaytux.phoebench"
|
||||||
version = "1.0.1"
|
version = PhoebenchVersion(1, 2, 1, "patch0")
|
||||||
|
|
||||||
|
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
|
||||||
|
throw GradleException("CLI Client version must be at least as high as protocol/common version")
|
||||||
|
|
||||||
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
|
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
|
||||||
val generateVersion = tasks.register<Task>("serverVersion") {
|
val generateVersion = tasks.register<Task>("serverVersion") {
|
||||||
@@ -19,18 +23,21 @@ val generateVersion = tasks.register<Task>("serverVersion") {
|
|||||||
package com.jaytux.phoebench.client.cli
|
package com.jaytux.phoebench.client.cli
|
||||||
|
|
||||||
object CliVersion {
|
object CliVersion {
|
||||||
const val VERSION="${rootProject.version}"
|
const val VERSION="$version"
|
||||||
}
|
}
|
||||||
""".trimIndent())
|
""".trimIndent())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isDebug = envValue("PHOEBENCH_BUILD_RELEASE") == null
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(kotlin("stdlib"))
|
implementation(kotlin("stdlib"))
|
||||||
implementation(libs.clikt)
|
implementation(libs.clikt)
|
||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.auth)
|
implementation(libs.ktor.client.auth)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.client.websocket)
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.kotlinx.serialization)
|
implementation(libs.kotlinx.serialization)
|
||||||
implementation(project(":common"))
|
implementation(project(":common"))
|
||||||
@@ -39,6 +46,7 @@ dependencies {
|
|||||||
implementation(libs.slf4j.simple)
|
implementation(libs.slf4j.simple)
|
||||||
implementation(libs.java.keystore)
|
implementation(libs.java.keystore)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
implementation(libs.process)
|
||||||
}
|
}
|
||||||
|
|
||||||
application {
|
application {
|
||||||
@@ -72,5 +80,26 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<ShadowJar> {
|
tasks.withType<ShadowJar> {
|
||||||
archiveFileName = "phoebench-cli.jar"
|
val archive = if(isDebug) "phoebench-cli-debug.jar" else "phoebench-cli.jar"
|
||||||
|
println("Using archive '$archive'")
|
||||||
|
archiveFileName = archive
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConfig {
|
||||||
|
generateAtSync = false
|
||||||
|
useKotlinOutput { internalVisibility = false }
|
||||||
|
|
||||||
|
println("Source sets: ${kotlin.sourceSets.toList().map { it.name }}")
|
||||||
|
|
||||||
|
className("PersistenceConstants")
|
||||||
|
packageName("com.jaytux.phoebench.clients.cli")
|
||||||
|
|
||||||
|
if(isDebug) {
|
||||||
|
println("Using debug CLI service")
|
||||||
|
buildConfigField("SERVICE", "com.jaytux.phoebench.cli.debug")
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
println("Using release CLI service")
|
||||||
|
buildConfigField("SERVICE", "com.jaytux.phoebench.cli")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,16 +6,21 @@ import kotlin.system.exitProcess
|
|||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
object AuthHandlers {
|
object AuthHandlers {
|
||||||
|
suspend fun checkServer() =
|
||||||
|
Client.callRoute(Routes.handshake, EmptyRequest()).bind { handshake ->
|
||||||
|
if(handshake.version != ProtocolVersion.VERSION) {
|
||||||
|
val server = Client.getServer() ?: "<unknown server ULR>"
|
||||||
|
Client.onClearServer()
|
||||||
|
ErrorResponse("Cannot use $server: protocol version mismatch (server: ${handshake.version}, expected: ${ProtocolVersion.VERSION})").error()
|
||||||
|
}
|
||||||
|
Unit.value()
|
||||||
|
}
|
||||||
|
|
||||||
fun serverPrompt(server: String?): Either<ErrorResponse, Unit> {
|
fun serverPrompt(server: String?): Either<ErrorResponse, Unit> {
|
||||||
val useServer = server.maybePrompt("server") { it }
|
val useServer = server.maybePrompt("server") { it }
|
||||||
Client.onSelectServer(useServer)
|
Client.onSelectServer(useServer)
|
||||||
return runBlocking {
|
return runBlocking {
|
||||||
Client.callRoute(Routes.handshake, EmptyRequest()).bind { handshake ->
|
checkServer().bind { Client.onSelectServer(useServer).value() }
|
||||||
if (handshake.version != ProtocolVersion.VERSION) {
|
|
||||||
Client.onClearServer()
|
|
||||||
ErrorResponse("Mismatched protocol version (server: ${handshake.version}, client: ${ProtocolVersion.VERSION})").error()
|
|
||||||
} else Unit.value()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.jaytux.phoebench.clients.cli
|
package com.jaytux.phoebench.clients.cli
|
||||||
|
|
||||||
import com.github.ajalt.clikt.core.*
|
import com.github.ajalt.clikt.core.*
|
||||||
|
import com.github.ajalt.clikt.parameters.arguments.argument
|
||||||
|
import com.github.ajalt.clikt.parameters.arguments.multiple
|
||||||
import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions
|
import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions
|
||||||
import com.github.ajalt.clikt.parameters.groups.single
|
import com.github.ajalt.clikt.parameters.groups.single
|
||||||
import com.github.ajalt.clikt.parameters.options.*
|
import com.github.ajalt.clikt.parameters.options.*
|
||||||
@@ -101,10 +103,11 @@ object CLI {
|
|||||||
override fun nestedCommands(): Array<CliktCommand> = fromReflection()
|
override fun nestedCommands(): Array<CliktCommand> = fromReflection()
|
||||||
|
|
||||||
sealed interface IProjectIdentification
|
sealed interface IProjectIdentification
|
||||||
sealed interface ILabelIdentification
|
sealed interface IVersionIdentification
|
||||||
|
sealed interface IBenchmarkIdentification
|
||||||
data class ProjectName(val user: String, val project: String) : IProjectIdentification
|
data class ProjectName(val user: String, val project: String) : IProjectIdentification
|
||||||
data class VersionName(val name: String): ILabelIdentification
|
data class Name(val name: String): IVersionIdentification, IBenchmarkIdentification
|
||||||
data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification
|
data class ID(val id: Uuid) : IProjectIdentification, IVersionIdentification, IBenchmarkIdentification
|
||||||
|
|
||||||
sealed interface IData<T> {
|
sealed interface IData<T> {
|
||||||
fun toList(): List<T>
|
fun toList(): List<T>
|
||||||
@@ -184,12 +187,24 @@ object CLI {
|
|||||||
override fun run() = ProjectHandlers.newVersion(name, color, parent.finder, meta)
|
override fun run() = ProjectHandlers.newVersion(name, color, parent.finder, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
class AddBenchmark : CliktCommand(name = "add-benchmark") {
|
||||||
|
val parent by requireObject<Project>()
|
||||||
|
val name by option("--name", help = "Set the version's name (*)")
|
||||||
|
|
||||||
|
override fun run() = ProjectHandlers.newBenchmark(name, parent.finder)
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
class AddData : CliktCommand(name = "add-data") {
|
class AddData : CliktCommand(name = "add-data") {
|
||||||
val parent by requireObject<Project>()
|
val parent by requireObject<Project>()
|
||||||
val version by mutuallyExclusiveOptions<ILabelIdentification>(
|
val version by mutuallyExclusiveOptions<IVersionIdentification>(
|
||||||
option("--version-id", help = "Set the version by UUID.").convert { ID(Uuid.parse(it)) },
|
option("--version-id", help = "Set the version by UUID.").convert { ID(Uuid.parse(it)) },
|
||||||
option("--version", help = "Set the version by name.").convert { VersionName(it) }
|
option("--version", help = "Set the version by name.").convert { Name(it) }
|
||||||
|
).single()
|
||||||
|
val benchmark by mutuallyExclusiveOptions<IBenchmarkIdentification>(
|
||||||
|
option("--benchmark-id", "--bench-id", help = "Set the benchmark by UUID.").convert { ID(Uuid.parse(it)) },
|
||||||
|
option("--benchmark", "--bench", help = "Set the benchmark by name.").convert { Name(it) }
|
||||||
).single()
|
).single()
|
||||||
val warmup by mutuallyExclusiveOptions<IData<Float>>(
|
val warmup by mutuallyExclusiveOptions<IData<Float>>(
|
||||||
option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
|
option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
|
||||||
@@ -210,7 +225,25 @@ object CLI {
|
|||||||
val input by option("--input", help = "Set the input used to benchmark")
|
val input by option("--input", help = "Set the input used to benchmark")
|
||||||
val hardware by option("--hardware", "--hw", help = "Set the hardware on which the benchmark was run")
|
val hardware by option("--hardware", "--hw", help = "Set the hardware on which the benchmark was run")
|
||||||
|
|
||||||
override fun run() = ProjectHandlers.newData(parent.finder, version, warmup, measurement, unit, input, hardware)
|
override fun run() = ProjectHandlers.newData(parent.finder, benchmark, version, warmup, measurement, unit, input, hardware)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
class Monitor : CliktCommand(name = "monitor") {
|
||||||
|
val finder by mutuallyExclusiveOptions<Project.IProjectIdentification>(
|
||||||
|
option("--id", help = "Find a project by UUID.").convert { Project.ID(Uuid.parse(it)) },
|
||||||
|
option("--name", help = "Find a project by name (formatted [user]/[project])").convert {
|
||||||
|
val split = it.split('/')
|
||||||
|
if(split.size != 2) throw IllegalArgumentException("Invalid format (expected [user]/[project])")
|
||||||
|
Project.ProjectName(split[0], split[1])
|
||||||
|
}
|
||||||
|
).single()
|
||||||
|
val command by argument("command", help = "The command to be run")
|
||||||
|
val commandArgs by argument("arguments", help = "Arguments to pass to the command").multiple()
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
MonitorHandler.monitor(finder, command, commandArgs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.jaytux.phoebench.clients.cli
|
package com.jaytux.phoebench.clients.cli
|
||||||
|
|
||||||
import com.jaytux.phoebench.common.ApiRoute
|
import com.jaytux.phoebench.common.ApiRoute
|
||||||
|
import com.jaytux.phoebench.common.CSERoute
|
||||||
import com.jaytux.phoebench.common.Either
|
import com.jaytux.phoebench.common.Either
|
||||||
import com.jaytux.phoebench.common.ErrorResponse
|
import com.jaytux.phoebench.common.ErrorResponse
|
||||||
import com.jaytux.phoebench.common.IClient
|
import com.jaytux.phoebench.common.IClient
|
||||||
@@ -9,15 +10,31 @@ import com.jaytux.phoebench.common.Routes
|
|||||||
import com.jaytux.phoebench.common.TokenResponse
|
import com.jaytux.phoebench.common.TokenResponse
|
||||||
import com.jaytux.phoebench.common.error
|
import com.jaytux.phoebench.common.error
|
||||||
import com.jaytux.phoebench.common.foldSuspend
|
import com.jaytux.phoebench.common.foldSuspend
|
||||||
|
import com.jaytux.phoebench.common.value
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.engine.okhttp.OkHttp
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
|
import io.ktor.client.plugins.ResponseException
|
||||||
import io.ktor.client.plugins.auth.Auth
|
import io.ktor.client.plugins.auth.Auth
|
||||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||||
import io.ktor.client.plugins.auth.providers.bearer
|
import io.ktor.client.plugins.auth.providers.bearer
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.plugins.websocket.WebSocketException
|
||||||
|
import io.ktor.client.plugins.websocket.WebSockets
|
||||||
|
import io.ktor.client.plugins.websocket.sendSerialized
|
||||||
|
import io.ktor.client.plugins.websocket.webSocket
|
||||||
|
import io.ktor.client.request.url
|
||||||
|
import io.ktor.http.HttpMethod
|
||||||
|
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import io.ktor.util.reflect.TypeInfo
|
||||||
|
import io.ktor.util.reflect.typeInfo
|
||||||
import io.ktor.utils.io.CancellationException
|
import io.ktor.utils.io.CancellationException
|
||||||
|
import io.ktor.websocket.CloseReason
|
||||||
import kotlinx.coroutines.asExecutor
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.net.URL
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
object Client {
|
object Client {
|
||||||
@@ -68,6 +85,10 @@ object Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install(WebSockets) {
|
||||||
|
contentConverter = KotlinxWebsocketSerializationConverter(Json)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -91,6 +112,19 @@ object Client {
|
|||||||
suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> =
|
suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> =
|
||||||
callRoute(_client, route, body)
|
callRoute(_client, route, body)
|
||||||
|
|
||||||
|
suspend fun <TParams: Any, TEvent: Any> callCSE(
|
||||||
|
route: CSERoute<TParams, TEvent>, params: TParams,
|
||||||
|
body: suspend (sender: suspend (TEvent) -> Unit) -> Unit
|
||||||
|
): Either<ErrorResponse, Unit> {
|
||||||
|
try {
|
||||||
|
val client = IClient.Default(_client, _server ?: throw IllegalStateException("No server URL set."))
|
||||||
|
return route.call(client, params, body)
|
||||||
|
}
|
||||||
|
catch(e: CancellationException) {
|
||||||
|
return ErrorResponse("Event stream disconnected.").error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onLogin(tokens: TokenResponse) {
|
fun onLogin(tokens: TokenResponse) {
|
||||||
_refreshToken = tokens.refresh
|
_refreshToken = tokens.refresh
|
||||||
_accessToken = tokens.access
|
_accessToken = tokens.access
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.jaytux.phoebench.clients.cli
|
||||||
|
|
||||||
|
import com.github.pgreze.process.Redirect
|
||||||
|
import com.github.pgreze.process.process
|
||||||
|
import com.jaytux.phoebench.clients.cli.ProjectHandlers.toId
|
||||||
|
import com.jaytux.phoebench.common.ANSI
|
||||||
|
import com.jaytux.phoebench.common.ClientMonitorEvent
|
||||||
|
import com.jaytux.phoebench.common.Routes
|
||||||
|
import com.jaytux.phoebench.common.Stream
|
||||||
|
import com.jaytux.phoebench.common.bind
|
||||||
|
import com.jaytux.phoebench.common.fold
|
||||||
|
import io.ktor.http.HttpMethod
|
||||||
|
import io.ktor.utils.io.CancellationException
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
object MonitorHandler {
|
||||||
|
private fun filterAnsi(line: String): Pair<String, List<ANSI>> {
|
||||||
|
val builder = StringBuilder()
|
||||||
|
val codes = mutableSetOf<ANSI>()
|
||||||
|
var remaining = line
|
||||||
|
while(remaining.isNotEmpty()) {
|
||||||
|
val next = remaining.indexOf("\u001B[")
|
||||||
|
if(next < 0) {
|
||||||
|
builder.append(remaining)
|
||||||
|
remaining = ""
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
builder.append(remaining.substring(0, next))
|
||||||
|
val end = remaining.indexOf('m', startIndex = next + 2)
|
||||||
|
if(end == -1) return (line to listOf()) // invalid...
|
||||||
|
codes += remaining.substring(next + 2, end).split(';').mapNotNull {
|
||||||
|
it.toIntOrNull()?.let { i -> ANSI.ansiMapping[i] }
|
||||||
|
}
|
||||||
|
remaining = remaining.substring(end + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.toString() to codes.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun monitor(find: CLI.Commands.Project.IProjectIdentification?, command: String, args: List<String>) {
|
||||||
|
val project = ProjectHandlers.ensureProjectIdentification(find)
|
||||||
|
val combinedCommand = arrayOf(command, *args.toTypedArray())
|
||||||
|
tryAuthenticated {
|
||||||
|
project.toId().bind { id ->
|
||||||
|
Client.callCSE(Routes.CSE.monitor, id) { sender ->
|
||||||
|
try {
|
||||||
|
val res = process(
|
||||||
|
*combinedCommand,
|
||||||
|
stdin = null,
|
||||||
|
stdout = Redirect.Consume { flow ->
|
||||||
|
flow.collect { line ->
|
||||||
|
val (updLine, options) = filterAnsi(line)
|
||||||
|
sender(ClientMonitorEvent.Message(msg = updLine, stream = Stream.STDOUT, options = options))
|
||||||
|
println(line)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stderr = Redirect.Consume { flow ->
|
||||||
|
flow.collect { line ->
|
||||||
|
val (updLine, options) = filterAnsi(line)
|
||||||
|
sender(ClientMonitorEvent.Message(msg = updLine, stream = Stream.STDERR, options = options))
|
||||||
|
System.err.println(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
sender(ClientMonitorEvent.ApplicationFinished(res.resultCode))
|
||||||
|
}
|
||||||
|
catch(e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
catch(e: Exception) {
|
||||||
|
System.err.println("Launch failed: ${e.message} (${e::class.simpleName})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.fold({
|
||||||
|
System.err.println("Failed to run: ${it.msg}")
|
||||||
|
}) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.jaytux.phoebench.clients.cli
|
|||||||
|
|
||||||
import com.github.javakeyring.BackendNotSupportedException
|
import com.github.javakeyring.BackendNotSupportedException
|
||||||
import com.github.javakeyring.Keyring
|
import com.github.javakeyring.Keyring
|
||||||
|
import com.github.javakeyring.PasswordAccessException
|
||||||
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
|
||||||
@@ -10,7 +11,7 @@ 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 = PersistenceConstants.SERVICE
|
||||||
private var _backend: IBackend = KeyringBackend
|
private var _backend: IBackend = KeyringBackend
|
||||||
|
|
||||||
interface IBackend {
|
interface IBackend {
|
||||||
@@ -22,7 +23,11 @@ object PersistentStorage {
|
|||||||
private object KeyringBackend : IBackend {
|
private object KeyringBackend : IBackend {
|
||||||
override fun read(key: String): String? = try {
|
override fun read(key: String): String? = try {
|
||||||
Keyring.create().use { it.getPassword(SERVICE, key) }
|
Keyring.create().use { it.getPassword(SERVICE, key) }
|
||||||
} catch(e: Exception) {
|
}
|
||||||
|
catch(e: PasswordAccessException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
catch(e: BackendNotSupportedException) {
|
||||||
_backend = PreferencesBackend
|
_backend = PreferencesBackend
|
||||||
_backend.read(key)
|
_backend.read(key)
|
||||||
}
|
}
|
||||||
@@ -52,6 +57,7 @@ object PersistentStorage {
|
|||||||
|
|
||||||
override fun write(key: String, value: String) {
|
override fun write(key: String, value: String) {
|
||||||
prefs.put(key, value)
|
prefs.put(key, value)
|
||||||
|
prefs.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun erase(key: String) {
|
override fun erase(key: String) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.jaytux.phoebench.clients.cli
|
package com.jaytux.phoebench.clients.cli
|
||||||
|
|
||||||
import com.jaytux.phoebench.clients.cli.CLI.Commands.Project.ProjectName
|
import com.jaytux.phoebench.clients.cli.CLI.Commands.Project.ProjectName
|
||||||
|
import com.jaytux.phoebench.common.BenchmarkRequest
|
||||||
import com.jaytux.phoebench.common.Either
|
import com.jaytux.phoebench.common.Either
|
||||||
import com.jaytux.phoebench.common.EmptyRequest
|
import com.jaytux.phoebench.common.EmptyRequest
|
||||||
import com.jaytux.phoebench.common.EntryRequest
|
import com.jaytux.phoebench.common.EntryRequest
|
||||||
@@ -29,6 +30,7 @@ object ProjectHandlers {
|
|||||||
Client.callRoute(Routes.home, EmptyRequest())
|
Client.callRoute(Routes.home, EmptyRequest())
|
||||||
}.fold({ err ->
|
}.fold({ err ->
|
||||||
System.err.println("Could not get projects list: ${err.msg}")
|
System.err.println("Could not get projects list: ${err.msg}")
|
||||||
|
exitProcess(-1)
|
||||||
}) {
|
}) {
|
||||||
System.err.println("Logged in as ${it.username}")
|
System.err.println("Logged in as ${it.username}")
|
||||||
(it.ownProjects + it.publicProjects).toSet().sortedBy { p -> p.name }.forEach { project ->
|
(it.ownProjects + it.publicProjects).toSet().sortedBy { p -> p.name }.forEach { project ->
|
||||||
@@ -65,21 +67,25 @@ object ProjectHandlers {
|
|||||||
}
|
}
|
||||||
}.fold({
|
}.fold({
|
||||||
System.err.println("Failed to get project details: ${it.msg}")
|
System.err.println("Failed to get project details: ${it.msg}")
|
||||||
|
exitProcess(-1)
|
||||||
}) {
|
}) {
|
||||||
println("Project ${it.owner.name}/${it.name} [${it.id}]:")
|
println("Project ${it.owner.name}/${it.name} [${it.id}]:")
|
||||||
println("${it.usedLabels.size} versions:")
|
println("${it.versions.size} versions:")
|
||||||
it.usedLabels.forEach { v ->
|
it.versions.forEach { v ->
|
||||||
println(" - [${v.id}] VERSION ${v.name} (with color ${v.color}); metadata: ${v.meta}")
|
println(" - [${v.id}] VERSION ${v.name} (with color ${v.color}); metadata: ${v.meta}")
|
||||||
}
|
}
|
||||||
val map = it.usedLabels.associateBy { l -> l.id }
|
val map = it.versions.associateBy { l -> l.id }
|
||||||
|
|
||||||
println("\n${it.entries.size} entries:")
|
println("\n${it.benchmarks.size} benchmarks:")
|
||||||
it.entries.forEach { e ->
|
it.benchmarks.forEach { b ->
|
||||||
println(" - [${e.id}] ENTRY versioned ${map[e.labelId]?.name ?: "<invalid version>"} at ${e.timestamp.fmt()} " +
|
println(" - [${b.id}] BENCHMARK ${b.name} (with ${b.entries.size} entries):")
|
||||||
|
b.entries.forEach { e ->
|
||||||
|
println(" - [${e.id}] ENTRY versioned ${map[e.versionId]?.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})")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun create(name: String?, isPublic: Boolean) {
|
fun create(name: String?, isPublic: Boolean) {
|
||||||
val useName = name.maybePrompt("project name") { it }
|
val useName = name.maybePrompt("project name") { it }
|
||||||
@@ -87,6 +93,7 @@ object ProjectHandlers {
|
|||||||
Client.callRoute(Routes.Project.new, ProjectRequest(useName, isPublic))
|
Client.callRoute(Routes.Project.new, ProjectRequest(useName, isPublic))
|
||||||
}.fold({
|
}.fold({
|
||||||
System.err.println("Failed to create project '$useName': ${it.msg}")
|
System.err.println("Failed to create project '$useName': ${it.msg}")
|
||||||
|
exitProcess(-1)
|
||||||
}) {
|
}) {
|
||||||
println("Project ${it.owner.name}/${it.name} [${it.id}] created.")
|
println("Project ${it.owner.name}/${it.name} [${it.id}] created.")
|
||||||
}
|
}
|
||||||
@@ -111,8 +118,25 @@ object ProjectHandlers {
|
|||||||
}
|
}
|
||||||
}.fold({
|
}.fold({
|
||||||
System.err.println("Could not create version: ${it.msg}")
|
System.err.println("Could not create version: ${it.msg}")
|
||||||
|
exitProcess(-1)
|
||||||
}) {
|
}) {
|
||||||
println("Label ${it.name} (${it.color}) [${it.id}] created.")
|
println("Version ${it.name} (${it.color}) [${it.id}] created.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newBenchmark(name: String?, project: CLI.Commands.Project.IProjectIdentification?) {
|
||||||
|
val id = ensureProjectIdentification(project)
|
||||||
|
val useName = name.maybePrompt("version name") { it }
|
||||||
|
|
||||||
|
tryAuthenticated {
|
||||||
|
id.toId().bind { projectId ->
|
||||||
|
Client.callRoute(Routes.Benchmark.new, BenchmarkRequest(useName, projectId))
|
||||||
|
}
|
||||||
|
}.fold({
|
||||||
|
System.err.println("Could not create benchmark: ${it.msg}")
|
||||||
|
exitProcess(-1)
|
||||||
|
}) {
|
||||||
|
println("Benchmark ${it.name} [${it.id}] created.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,14 +153,18 @@ object ProjectHandlers {
|
|||||||
|
|
||||||
fun newData(
|
fun newData(
|
||||||
project: CLI.Commands.Project.IProjectIdentification?,
|
project: CLI.Commands.Project.IProjectIdentification?,
|
||||||
label: CLI.Commands.Project.ILabelIdentification?,
|
benchmark: CLI.Commands.Project.IBenchmarkIdentification?,
|
||||||
|
label: CLI.Commands.Project.IVersionIdentification?,
|
||||||
warmup: CLI.Commands.Project.IData<Float>?,
|
warmup: CLI.Commands.Project.IData<Float>?,
|
||||||
measurement: CLI.Commands.Project.IData<Float>?,
|
measurement: CLI.Commands.Project.IData<Float>?,
|
||||||
unit: TimeUnit?, input: String?, hardware: String?
|
unit: TimeUnit?, input: String?, hardware: String?
|
||||||
) {
|
) {
|
||||||
val projectId = ensureProjectIdentification(project)
|
val projectId = ensureProjectIdentification(project)
|
||||||
val versionId = label.maybePrompt("version name") {
|
val versionId = label.maybePrompt("version name") {
|
||||||
CLI.Commands.Project.VersionName(it)
|
CLI.Commands.Project.Name(it)
|
||||||
|
}
|
||||||
|
val benchmarkId = benchmark.maybePrompt("benchmark name") {
|
||||||
|
CLI.Commands.Project.Name(it)
|
||||||
}
|
}
|
||||||
val warmupData = warmup.ensure("warmup").toList()
|
val warmupData = warmup.ensure("warmup").toList()
|
||||||
val measureData = measurement.ensure("measurements").toList()
|
val measureData = measurement.ensure("measurements").toList()
|
||||||
@@ -160,19 +188,30 @@ object ProjectHandlers {
|
|||||||
|
|
||||||
tryAuthenticated {
|
tryAuthenticated {
|
||||||
projectId.toId().bind { pId ->
|
projectId.toId().bind { pId ->
|
||||||
when(versionId) {
|
Client.callRoute(Routes.Project.get, pId).bind { p ->
|
||||||
|
when (versionId) {
|
||||||
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.Name -> p.versions.firstOrNull { l -> l.name == versionId.name }?.id?.value()
|
||||||
it.usedLabels.firstOrNull { l -> l.name == versionId.name }?.id?.value()
|
|
||||||
?: ErrorResponse("Version ${versionId.name} does not exist in this project.").error()
|
?: ErrorResponse("Version ${versionId.name} does not exist in this project.").error()
|
||||||
|
}.bind { vId ->
|
||||||
|
when (benchmarkId) {
|
||||||
|
is CLI.Commands.Project.ID -> benchmarkId.id.value()
|
||||||
|
is CLI.Commands.Project.Name -> p.benchmarks.firstOrNull { b -> b.name == benchmarkId.name }?.id?.value()
|
||||||
|
?: ErrorResponse("Benchmark ${benchmarkId.name} does not exist in this project.").error()
|
||||||
|
}.map { it to vId }
|
||||||
}
|
}
|
||||||
}.map { pId to it }
|
}
|
||||||
}.bind { (pId, vId) ->
|
}.bind { (bId, vId) ->
|
||||||
Client.callRoute(Routes.Entry.new, EntryRequest(
|
Client.callRoute(Routes.Entry.new, EntryRequest(
|
||||||
vId, Clock.System.now(), pId,
|
vId, Clock.System.now(), bId,
|
||||||
warmupData, measureData, timeUnit, useInput, useHardware
|
warmupData, measureData, timeUnit, useInput, useHardware
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
}.fold({
|
||||||
|
System.err.println("Failed to add data: ${it.msg}")
|
||||||
|
exitProcess(-1)
|
||||||
|
}) {
|
||||||
|
println("Data added successfully.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,10 @@ plugins {
|
|||||||
alias(libs.plugins.buildconfig)
|
alias(libs.plugins.buildconfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
version = "1.0.1"
|
version = PhoebenchVersion(1, 2, 1, "autoscroll")
|
||||||
|
|
||||||
|
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
|
||||||
|
throw GradleException("UI Clients version must be at least as high as protocol/common version")
|
||||||
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
|
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
|
||||||
|
|
||||||
val generateVersion = tasks.register<Task>("clientVersion") {
|
val generateVersion = tasks.register<Task>("clientVersion") {
|
||||||
@@ -24,7 +27,7 @@ val generateVersion = tasks.register<Task>("clientVersion") {
|
|||||||
package com.jaytux.phoebench.client
|
package com.jaytux.phoebench.client
|
||||||
|
|
||||||
object ClientVersion {
|
object ClientVersion {
|
||||||
const val VERSION="${rootProject.version}"
|
const val VERSION="$version"
|
||||||
}
|
}
|
||||||
""".trimIndent())
|
""".trimIndent())
|
||||||
}
|
}
|
||||||
@@ -128,7 +131,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 = version.toString()
|
packageVersion = (version as PhoebenchVersion).safeString()
|
||||||
|
|
||||||
jvmArgs += listOf("--enable-native-access=ALL-UNNAMED")
|
jvmArgs += listOf("--enable-native-access=ALL-UNNAMED")
|
||||||
includeAllModules = true
|
includeAllModules = true
|
||||||
@@ -139,31 +142,23 @@ 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 {
|
buildConfig {
|
||||||
sourceSets.getByName("wasmJsMain") {
|
generateAtSync = false
|
||||||
val serverUrl = envValue("WASM_SERVER_URL") ?:
|
useKotlinOutput { internalVisibility = false }
|
||||||
throw GradleException("MISSING CONFIGURATION: WASM_SERVER_URL. Please set it as environment variable or `.env` variable.")
|
|
||||||
|
|
||||||
className("WasmConfig")
|
println("Source sets: ${kotlin.sourceSets.toList().map { it.name }}")
|
||||||
|
|
||||||
|
sourceSets.named("desktopMain") {
|
||||||
|
println("Generating 'desktopMain'")
|
||||||
|
className("PersistenceConstants")
|
||||||
packageName("com.jaytux.phoebench.clients")
|
packageName("com.jaytux.phoebench.clients")
|
||||||
useKotlinOutput()
|
|
||||||
buildConfigField("SERVER_URL", serverUrl)
|
envValue("PHOEBENCH_BUILD_RELEASE")?.let {
|
||||||
|
println("Using release desktop service")
|
||||||
|
buildConfigField("SERVICE", "com.jaytux.phoebench")
|
||||||
|
} ?: run {
|
||||||
|
println("Using debug desktop service")
|
||||||
|
buildConfigField("SERVICE", "com.jaytux.phoebench.debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,10 @@ package com.jaytux.phoebench.clients
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.jaytux.phoebench.clients.ui.RootView
|
import com.jaytux.phoebench.clients.ui.RootView
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.jaytux.phoebench.clients
|
package com.jaytux.phoebench.clients
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropTransferData
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.*
|
||||||
import io.ktor.client.HttpClientConfig
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
interface IStore {
|
interface IStore {
|
||||||
@@ -27,3 +30,11 @@ expect fun format(f: Float): String
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun ServerSelect(server: String, onUpdate: (String) -> Unit)
|
expect fun ServerSelect(server: String, onUpdate: (String) -> Unit)
|
||||||
|
|
||||||
|
inline fun <reified T> drag(data: T) = drag(data, serializer<T>())
|
||||||
|
|
||||||
|
expect fun <T> drag(data: T, serializer: KSerializer<T>): DragAndDropTransferData
|
||||||
|
|
||||||
|
inline fun <reified T> drop(data: DragAndDropEvent): T? = drop(data, serializer<T>())
|
||||||
|
|
||||||
|
expect fun <T> drop(data: DragAndDropEvent, serializer: KSerializer<T>): T?
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
package com.jaytux.phoebench.clients
|
package com.jaytux.phoebench.clients
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.jaytux.phoebench.common.Either
|
import com.jaytux.phoebench.common.Either
|
||||||
@@ -18,8 +24,10 @@ import kotlinx.datetime.format.MonthNames
|
|||||||
import kotlinx.datetime.format.Padding
|
import kotlinx.datetime.format.Padding
|
||||||
import kotlinx.datetime.format.char
|
import kotlinx.datetime.format.char
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import org.jetbrains.compose.resources.Font
|
||||||
|
import phoebench.clients.compose.generated.resources.*
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.pow
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import kotlin.random.nextInt
|
import kotlin.random.nextInt
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -85,8 +93,21 @@ 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 {
|
inline fun <T, X : Comparable<X>> List<T>.insortIdx(elem: T, crossinline sortBy: (T) -> X): Pair<List<T>, Int?> {
|
||||||
return (size.toFloat() / sumOf { 1.0 / it.toDouble() }).toFloat()
|
val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy)
|
||||||
|
if(insertionPoint >= 0) return this to null
|
||||||
|
val index = -insertionPoint - 1
|
||||||
|
|
||||||
|
return toMutableList().apply { add(index, elem) } to index
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> List<T>.replace(crossinline finder: (T) -> Boolean, crossinline update: (T) -> T): List<T> {
|
||||||
|
val idx = indexOfFirst { finder(it) }
|
||||||
|
if(idx == -1) return this
|
||||||
|
|
||||||
|
val res = ArrayList(this)
|
||||||
|
res[idx] = update(res[idx])
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
|
infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
|
||||||
@@ -123,3 +144,23 @@ fun <T1, T2, T3, T4> List<Tuple4<T1, T2, T3, T4>>.unzip(): Tuple4<List<T1>, List
|
|||||||
|
|
||||||
return l1 to l2 app l3 app l4
|
return l1 to l2 app l3 app l4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun <T> List<T>.removeAt(idx: Int): List<T> = subList(0, idx) + subList(idx + 1, size)
|
||||||
|
|
||||||
|
fun List<Float>.geomean(): Float {
|
||||||
|
var prod = 1.0f
|
||||||
|
for(value in this) prod *= value
|
||||||
|
return prod.pow(1.0f / size.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TextStyle.makeMonospaced(): TextStyle {
|
||||||
|
val family = FontFamily(
|
||||||
|
Font(Res.font.JetBrainsMono_Regular, weight = FontWeight.Normal, style = FontStyle.Normal),
|
||||||
|
Font(Res.font.JetBrainsMono_Bold, weight = FontWeight.Bold, style = FontStyle.Normal),
|
||||||
|
Font(Res.font.JetBrainsMono_Italic, weight = FontWeight.Normal, style = FontStyle.Italic),
|
||||||
|
Font(Res.font.JetBrainsMono_BoldItalic, weight = FontWeight.Bold, style = FontStyle.Italic)
|
||||||
|
)
|
||||||
|
|
||||||
|
return copy(fontFamily = family)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import com.jaytux.phoebench.common.Either
|
|||||||
import com.jaytux.phoebench.common.HomeEvent
|
import com.jaytux.phoebench.common.HomeEvent
|
||||||
import com.jaytux.phoebench.common.HomeResponse
|
import com.jaytux.phoebench.common.HomeResponse
|
||||||
import com.jaytux.phoebench.common.InviteListResponse
|
import com.jaytux.phoebench.common.InviteListResponse
|
||||||
|
import com.jaytux.phoebench.common.Migrate0To1Request
|
||||||
import com.jaytux.phoebench.common.Routes
|
import com.jaytux.phoebench.common.Routes
|
||||||
import com.jaytux.phoebench.common.UserListResponse
|
import com.jaytux.phoebench.common.UserListResponse
|
||||||
import com.jaytux.phoebench.common.value
|
import com.jaytux.phoebench.common.value
|
||||||
@@ -40,11 +41,14 @@ class HomeVM(
|
|||||||
private val _users = mutableStateOf(listOf<UserListResponse.UserData>())
|
private val _users = mutableStateOf(listOf<UserListResponse.UserData>())
|
||||||
private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>())
|
private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>())
|
||||||
|
|
||||||
|
private val _stepsNeeded = mutableStateOf(listOf<HomeResponse.NeededMigration>())
|
||||||
|
|
||||||
val username = _username.immutable()
|
val username = _username.immutable()
|
||||||
val isAdmin = _isAdmin.immutable()
|
val isAdmin = _isAdmin.immutable()
|
||||||
val projectLimit = _projectLimit.immutable()
|
val projectLimit = _projectLimit.immutable()
|
||||||
val ownProjects = _ownProjects.immutable()
|
val ownProjects = _ownProjects.immutable()
|
||||||
val publicProjects = _publicProjects.immutable()
|
val publicProjects = _publicProjects.immutable()
|
||||||
|
val stepsNeeded = _stepsNeeded.immutable()
|
||||||
|
|
||||||
val invites = _invites.immutable()
|
val invites = _invites.immutable()
|
||||||
val users = _users.immutable()
|
val users = _users.immutable()
|
||||||
@@ -81,16 +85,21 @@ class HomeVM(
|
|||||||
withScope {
|
withScope {
|
||||||
resetAdmin()
|
resetAdmin()
|
||||||
_repo.getHome().snackOr {
|
_repo.getHome().snackOr {
|
||||||
|
refreshFrom(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshFrom(it: HomeResponse) {
|
||||||
println("Received home response $it")
|
println("Received home response $it")
|
||||||
_username.value = it.username
|
_username.value = it.username
|
||||||
_isAdmin.value = it.isAdmin
|
_isAdmin.value = it.isAdmin
|
||||||
_projectLimit.value = it.projectLimit
|
_projectLimit.value = it.projectLimit
|
||||||
_ownProjects.value = it.ownProjects.sortedBy { p -> p.name }
|
_ownProjects.value = it.ownProjects.sortedBy { p -> p.name }
|
||||||
_publicProjects.value = it.publicProjects.sortedBy { p -> p.name }
|
_publicProjects.value = it.publicProjects.sortedBy { p -> p.name }
|
||||||
|
_stepsNeeded.value = it.neededMigrations
|
||||||
refreshAdmin()
|
refreshAdmin()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshAdmin() {
|
fun refreshAdmin() {
|
||||||
if(_isAdmin.value) {
|
if(_isAdmin.value) {
|
||||||
@@ -212,4 +221,12 @@ class HomeVM(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun migrate(v0to1: List<Migrate0To1Request.MigrateProject>) {
|
||||||
|
withScope {
|
||||||
|
_repo.migrate0To1(v0to1).snackOr {
|
||||||
|
refreshFrom(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import com.jaytux.phoebench.common.HomeResponse
|
|||||||
import com.jaytux.phoebench.common.InviteListResponse
|
import com.jaytux.phoebench.common.InviteListResponse
|
||||||
import com.jaytux.phoebench.common.InviteRequest
|
import com.jaytux.phoebench.common.InviteRequest
|
||||||
import com.jaytux.phoebench.common.LogoutRequest
|
import com.jaytux.phoebench.common.LogoutRequest
|
||||||
|
import com.jaytux.phoebench.common.Migrate0To1Request
|
||||||
import com.jaytux.phoebench.common.ProjectRequest
|
import com.jaytux.phoebench.common.ProjectRequest
|
||||||
import com.jaytux.phoebench.common.Routes
|
import com.jaytux.phoebench.common.Routes
|
||||||
import com.jaytux.phoebench.common.UserListResponse
|
import com.jaytux.phoebench.common.UserListResponse
|
||||||
@@ -31,6 +32,8 @@ interface IHomeRepo {
|
|||||||
|
|
||||||
suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit>
|
suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit>
|
||||||
|
|
||||||
|
suspend fun migrate0To1(conversions: List<Migrate0To1Request.MigrateProject>): Either<ErrorResponse, HomeResponse>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
class Default(private val _client: Client) : IHomeRepo {
|
class Default(private val _client: Client) : IHomeRepo {
|
||||||
override suspend fun getHome(): Either<ErrorResponse, HomeResponse> =
|
override suspend fun getHome(): Either<ErrorResponse, HomeResponse> =
|
||||||
@@ -62,6 +65,9 @@ interface IHomeRepo {
|
|||||||
|
|
||||||
override suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> =
|
override suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> =
|
||||||
_client.callRoute(Routes.Project.new, ProjectRequest(name, isPublic)).ignoreValue()
|
_client.callRoute(Routes.Project.new, ProjectRequest(name, isPublic)).ignoreValue()
|
||||||
|
|
||||||
|
override suspend fun migrate0To1(conversions: List<Migrate0To1Request.MigrateProject>): Either<ErrorResponse, HomeResponse> =
|
||||||
|
_client.callRoute(Routes.migrate0To1, Migrate0To1Request(conversions))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun default(client: Client) = Default(client)
|
fun default(client: Client) = Default(client)
|
||||||
|
|||||||
+24
-3
@@ -4,10 +4,13 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import com.jaytux.phoebench.clients.Client
|
import com.jaytux.phoebench.clients.Client
|
||||||
import com.jaytux.phoebench.clients.hexString
|
import com.jaytux.phoebench.clients.hexString
|
||||||
import com.jaytux.phoebench.clients.ignoreValue
|
import com.jaytux.phoebench.clients.ignoreValue
|
||||||
|
import com.jaytux.phoebench.common.BenchmarkRequest
|
||||||
|
import com.jaytux.phoebench.common.BenchmarkResponse
|
||||||
import com.jaytux.phoebench.common.Either
|
import com.jaytux.phoebench.common.Either
|
||||||
import com.jaytux.phoebench.common.EntryRequest
|
import com.jaytux.phoebench.common.EntryRequest
|
||||||
import com.jaytux.phoebench.common.EntryResponse
|
import com.jaytux.phoebench.common.EntryResponse
|
||||||
import com.jaytux.phoebench.common.ErrorResponse
|
import com.jaytux.phoebench.common.ErrorResponse
|
||||||
|
import com.jaytux.phoebench.common.PartialBenchmarkRequest
|
||||||
import com.jaytux.phoebench.common.VersionRequest
|
import com.jaytux.phoebench.common.VersionRequest
|
||||||
import com.jaytux.phoebench.common.VersionResponse
|
import com.jaytux.phoebench.common.VersionResponse
|
||||||
import com.jaytux.phoebench.common.PartialProjectRequest
|
import com.jaytux.phoebench.common.PartialProjectRequest
|
||||||
@@ -23,14 +26,20 @@ interface IProjectRepo {
|
|||||||
suspend fun update(name: String? = null, isPublic: Boolean? = null): Either<ErrorResponse, Unit>
|
suspend fun update(name: String? = null, isPublic: Boolean? = null): Either<ErrorResponse, Unit>
|
||||||
suspend fun delete(): Either<ErrorResponse, Unit>
|
suspend fun delete(): Either<ErrorResponse, Unit>
|
||||||
|
|
||||||
|
suspend fun newBenchmark(name: String): Either<ErrorResponse, BenchmarkResponse>
|
||||||
|
suspend fun updateBenchmark(id: Uuid, name: String? = null): Either<ErrorResponse, Unit>
|
||||||
|
suspend fun deleteBenchmark(id: Uuid): Either<ErrorResponse, Unit>
|
||||||
|
|
||||||
suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse>
|
suspend fun 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 deleteVersion(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(benchmark: Uuid, 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>
|
||||||
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
|
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
|
||||||
|
|
||||||
|
suspend fun deleteMonitorLogs(): Either<ErrorResponse, Unit>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
class Default(private val _client: Client, private val _projectId: Uuid) : IProjectRepo {
|
class Default(private val _client: Client, private val _projectId: Uuid) : IProjectRepo {
|
||||||
override suspend fun get(): Either<ErrorResponse, ProjectResponse> =
|
override suspend fun get(): Either<ErrorResponse, ProjectResponse> =
|
||||||
@@ -42,6 +51,15 @@ interface IProjectRepo {
|
|||||||
override suspend fun delete(): Either<ErrorResponse, Unit> =
|
override suspend fun delete(): Either<ErrorResponse, Unit> =
|
||||||
_client.callRoute(Routes.Project.delete, _projectId).ignoreValue()
|
_client.callRoute(Routes.Project.delete, _projectId).ignoreValue()
|
||||||
|
|
||||||
|
override suspend fun newBenchmark(name: String): Either<ErrorResponse, BenchmarkResponse> =
|
||||||
|
_client.callRoute(Routes.Benchmark.new, BenchmarkRequest(name, _projectId))
|
||||||
|
|
||||||
|
override suspend fun updateBenchmark(id: Uuid, name: String?): Either<ErrorResponse, Unit> =
|
||||||
|
_client.callRoute(Routes.Benchmark.update, id to PartialBenchmarkRequest(name = name)).ignoreValue()
|
||||||
|
|
||||||
|
override suspend fun deleteBenchmark(id: Uuid): Either<ErrorResponse, Unit> =
|
||||||
|
_client.callRoute(Routes.Benchmark.delete, id).ignoreValue()
|
||||||
|
|
||||||
override suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse> =
|
override suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse> =
|
||||||
_client.callRoute(Routes.Version.new, VersionRequest(name, color.hexString(), meta, _projectId))
|
_client.callRoute(Routes.Version.new, VersionRequest(name, color.hexString(), meta, _projectId))
|
||||||
|
|
||||||
@@ -51,13 +69,16 @@ interface IProjectRepo {
|
|||||||
override suspend fun deleteVersion(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(benchmark: Uuid, label: Uuid, timestamp: Instant, warmups: List<Float>,
|
||||||
measurements: List<Float>, unit: TimeUnit, input: String, hardware: String
|
measurements: List<Float>, unit: TimeUnit, input: String, hardware: String
|
||||||
): Either<ErrorResponse, EntryResponse> =
|
): Either<ErrorResponse, EntryResponse> =
|
||||||
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit, input, hardware))
|
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, benchmark, warmups, measurements, unit, input, hardware))
|
||||||
|
|
||||||
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
|
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
|
||||||
_client.callRoute(Routes.Entry.delete, id).ignoreValue()
|
_client.callRoute(Routes.Entry.delete, id).ignoreValue()
|
||||||
|
|
||||||
|
override suspend fun deleteMonitorLogs(): Either<ErrorResponse, Unit> =
|
||||||
|
_client.callRoute(Routes.Project.rmMonitor, _projectId).ignoreValue()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun default(client: Client, projectId: Uuid) = Default(client, projectId)
|
fun default(client: Client, projectId: Uuid) = Default(client, projectId)
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import com.jaytux.phoebench.common.ErrorResponse
|
|||||||
import com.jaytux.phoebench.common.HomeEvent
|
import com.jaytux.phoebench.common.HomeEvent
|
||||||
import com.jaytux.phoebench.common.ProjectEvent
|
import com.jaytux.phoebench.common.ProjectEvent
|
||||||
import com.jaytux.phoebench.common.Routes
|
import com.jaytux.phoebench.common.Routes
|
||||||
|
import com.jaytux.phoebench.common.ServerMonitorEvent
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
interface ISSERepo {
|
interface ISSERepo {
|
||||||
suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit>
|
suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit>
|
||||||
suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit>
|
suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit>
|
||||||
suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit>
|
suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit>
|
||||||
|
suspend fun connectMonitor(id: Uuid, onEvent: suspend (Either<ErrorResponse, ServerMonitorEvent>) -> Unit): Either<ErrorResponse, Unit>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
class Default(private val _client: Client) : ISSERepo {
|
class Default(private val _client: Client) : ISSERepo {
|
||||||
@@ -24,6 +26,9 @@ interface ISSERepo {
|
|||||||
|
|
||||||
override suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit> =
|
override suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit> =
|
||||||
_client.callSSE(Routes.SSE.projectSpecific, id, onEvent)
|
_client.callSSE(Routes.SSE.projectSpecific, id, onEvent)
|
||||||
|
|
||||||
|
override suspend fun connectMonitor(id: Uuid, onEvent: suspend (Either<ErrorResponse, ServerMonitorEvent>) -> Unit): Either<ErrorResponse, Unit> =
|
||||||
|
_client.callSSE(Routes.SSE.monitor, id, onEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun default(client: Client) = Default(client)
|
fun default(client: Client) = Default(client)
|
||||||
|
|||||||
+144
-25
@@ -1,5 +1,6 @@
|
|||||||
package com.jaytux.phoebench.clients.data
|
package com.jaytux.phoebench.clients.data
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
@@ -11,11 +12,16 @@ import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
|
|||||||
import com.jaytux.phoebench.clients.hexString
|
import com.jaytux.phoebench.clients.hexString
|
||||||
import com.jaytux.phoebench.clients.immutable
|
import com.jaytux.phoebench.clients.immutable
|
||||||
import com.jaytux.phoebench.clients.insort
|
import com.jaytux.phoebench.clients.insort
|
||||||
|
import com.jaytux.phoebench.clients.insortIdx
|
||||||
|
import com.jaytux.phoebench.clients.replace
|
||||||
import com.jaytux.phoebench.clients.withScope
|
import com.jaytux.phoebench.clients.withScope
|
||||||
|
import com.jaytux.phoebench.common.BenchmarkResponse
|
||||||
import com.jaytux.phoebench.common.EntryResponse
|
import com.jaytux.phoebench.common.EntryResponse
|
||||||
import com.jaytux.phoebench.common.VersionResponse
|
import com.jaytux.phoebench.common.VersionResponse
|
||||||
import com.jaytux.phoebench.common.ProjectEvent
|
import com.jaytux.phoebench.common.ProjectEvent
|
||||||
|
import com.jaytux.phoebench.common.ServerMonitorEvent
|
||||||
import com.jaytux.phoebench.common.TimeUnit
|
import com.jaytux.phoebench.common.TimeUnit
|
||||||
|
import com.jaytux.phoebench.common.fold
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -57,9 +63,20 @@ class ProjectVM(
|
|||||||
data class Entry(val id: Uuid, val label: Version, val timeStamp: Instant, val warmups: List<Float>,
|
data class Entry(val id: Uuid, val label: Version, val timeStamp: Instant, val warmups: List<Float>,
|
||||||
val measurements: List<Float>, val nativeUnit: TimeUnit, val input: String, val hardware: String) {
|
val measurements: List<Float>, val nativeUnit: TimeUnit, val input: String, val hardware: String) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>) = Entry(
|
inline fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>, addInput: (String) -> Unit, addHardware: (String) -> Unit) = Entry(
|
||||||
it.id, map[it.labelId] ?: Version.invalid, it.timestamp,
|
it.id, map[it.versionId] ?: Version.invalid, it.timestamp,
|
||||||
it.warmups, it.measurements, it.unit, it.input, it.hardware
|
it.warmups, it.measurements, it.unit, it.input, it.hardware
|
||||||
|
).also { addInput(it.input); addHardware(it.hardware) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Benchmark(val id: Uuid, val name: String, val entries: List<Entry>) {
|
||||||
|
fun add(entry: Entry) = copy(entries = entries.insort(entry) { it.timeStamp })
|
||||||
|
fun remove(entry: Uuid) = copy(entries = entries.filter { it.id != entry })
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
inline fun fromResponse(it: BenchmarkResponse, map: Map<Uuid, Version>, addInput: (String) -> Unit, addHardware: (String) -> Unit) = Benchmark(
|
||||||
|
it.id, it.name, it.entries.map { er -> Entry.fromResponse(er, map, addInput, addHardware) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,20 +86,25 @@ class ProjectVM(
|
|||||||
private val _public = mutableStateOf(false)
|
private val _public = mutableStateOf(false)
|
||||||
private val _editable = mutableStateOf(false)
|
private val _editable = mutableStateOf(false)
|
||||||
private val _labels = mutableStateOf(mapOf<Uuid, Version>())
|
private val _labels = mutableStateOf(mapOf<Uuid, Version>())
|
||||||
private val _entries = mutableStateOf(listOf<Entry>())
|
private val _benchmarks = mutableStateOf(listOf<Benchmark>())
|
||||||
|
private val _currentBenchmark = mutableStateOf(0)
|
||||||
private val _inputs = mutableStateOf(setOf<String>())
|
private val _inputs = mutableStateOf(setOf<String>())
|
||||||
private val _hardware = mutableStateOf(setOf<String>())
|
private val _hardware = mutableStateOf(setOf<String>())
|
||||||
|
private val _monitorMessages = mutableStateOf<List<ServerMonitorEvent.ITextEvent>?>(null)
|
||||||
|
|
||||||
val name = _name.immutable()
|
val name = _name.immutable()
|
||||||
val owner = _owner.immutable()
|
val owner = _owner.immutable()
|
||||||
val public = _public.immutable()
|
val public = _public.immutable()
|
||||||
val editable = _editable.immutable()
|
val editable = _editable.immutable()
|
||||||
val versions = _labels.immutable()
|
val versions = _labels.immutable()
|
||||||
val entries = _entries.immutable()
|
val benchmarks = _benchmarks.immutable()
|
||||||
|
val currentBenchmark = _currentBenchmark.immutable()
|
||||||
val inputs = _inputs.immutable()
|
val inputs = _inputs.immutable()
|
||||||
val hardware = _hardware.immutable()
|
val hardware = _hardware.immutable()
|
||||||
|
val monitorMessages = _monitorMessages.immutable()
|
||||||
|
|
||||||
private var _job: Job? = null
|
private var _job: Job? = null
|
||||||
|
private var _monitorJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
_job = withScope {
|
_job = withScope {
|
||||||
@@ -102,14 +124,10 @@ class ProjectVM(
|
|||||||
_owner.value = it.owner.name
|
_owner.value = it.owner.name
|
||||||
_public.value = it.isPublic
|
_public.value = it.isPublic
|
||||||
_editable.value = it.isEditable
|
_editable.value = it.isEditable
|
||||||
_labels.value = it.usedLabels.associate { l -> l.id to Version.fromResponse(l) }
|
_labels.value = it.versions.associate { l -> l.id to Version.fromResponse(l) }
|
||||||
val localInputs = mutableSetOf<String>()
|
val localInputs = mutableSetOf<String>()
|
||||||
val localHardware = mutableSetOf<String>()
|
val localHardware = mutableSetOf<String>()
|
||||||
_entries.value = it.entries.map { e ->
|
_benchmarks.value = it.benchmarks.map { b -> Benchmark.fromResponse(b, _labels.value, localInputs::add, localHardware::add) }
|
||||||
localInputs += e.input
|
|
||||||
localHardware += e.hardware
|
|
||||||
Entry.fromResponse(e, _labels.value)
|
|
||||||
}
|
|
||||||
_inputs.value = localInputs
|
_inputs.value = localInputs
|
||||||
_hardware.value = localHardware
|
_hardware.value = localHardware
|
||||||
}
|
}
|
||||||
@@ -119,13 +137,19 @@ class ProjectVM(
|
|||||||
private fun handleProjectEvent(event: ProjectEvent) {
|
private fun handleProjectEvent(event: ProjectEvent) {
|
||||||
when(event) {
|
when(event) {
|
||||||
ProjectEvent.Deleted -> back()
|
ProjectEvent.Deleted -> back()
|
||||||
is ProjectEvent.EntryDeleted -> _entries.value = _entries.value.filter { it.id != event.id }
|
is ProjectEvent.EntryDeleted -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmarkId }) { it.remove(event.id) }
|
||||||
is ProjectEvent.VersionChanged -> _labels.value += (event.label.id to Version.fromResponse(event.label))
|
is ProjectEvent.VersionChanged -> {
|
||||||
is ProjectEvent.VersionDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id }
|
val updVersion = Version.fromResponse(event.label)
|
||||||
|
_labels.value += (event.label.id to updVersion)
|
||||||
|
_benchmarks.value = _benchmarks.value.map { b -> b.copy(entries = b.entries.map { e -> if(e.label.id == event.label.id) e.copy(label = updVersion) else e }) }
|
||||||
|
}
|
||||||
|
is ProjectEvent.VersionDeleted -> {
|
||||||
|
_labels.value = _labels.value.filter { it.value.id != event.id }
|
||||||
|
_benchmarks.value = _benchmarks.value.map { it.copy(entries = it.entries.filter { e -> e.label.id != event.id }) }
|
||||||
|
}
|
||||||
is ProjectEvent.NewEntry -> {
|
is ProjectEvent.NewEntry -> {
|
||||||
_entries.value = _entries.value.insort(Entry.fromResponse(event.entry, _labels.value), Entry::timeStamp)
|
_benchmarks.value = _benchmarks.value.replace({ it.id == event.entry.benchmarkId }) { it.add(Entry.fromResponse(event.entry, _labels.value,
|
||||||
_inputs.value += event.entry.input
|
{ input -> _inputs.value += input }, { hw -> _hardware.value += hw })) }
|
||||||
_hardware.value += event.entry.hardware
|
|
||||||
}
|
}
|
||||||
is ProjectEvent.NewVersion -> _labels.value += (event.label.id to Version.fromResponse(event.label))
|
is ProjectEvent.NewVersion -> _labels.value += (event.label.id to Version.fromResponse(event.label))
|
||||||
is ProjectEvent.Updated -> {
|
is ProjectEvent.Updated -> {
|
||||||
@@ -133,6 +157,23 @@ class ProjectVM(
|
|||||||
_owner.value = event.changes.owner.name
|
_owner.value = event.changes.owner.name
|
||||||
_public.value = event.changes.isPublic
|
_public.value = event.changes.isPublic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is ProjectEvent.BenchmarkChanged -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmark.id }) { it.copy(name = event.benchmark.name) }
|
||||||
|
is ProjectEvent.BenchmarkDeleted -> {
|
||||||
|
val idx = _benchmarks.value.indexOfFirst { it.id == event.id }
|
||||||
|
if(idx == _currentBenchmark.value) _currentBenchmark.value = 0
|
||||||
|
else if(idx < _currentBenchmark.value) _currentBenchmark.value -= 1
|
||||||
|
_benchmarks.value = _benchmarks.value.filter { it.id != event.id }
|
||||||
|
}
|
||||||
|
is ProjectEvent.NewBenchmark -> {
|
||||||
|
val (updated, idx) = _benchmarks.value.insortIdx(
|
||||||
|
Benchmark.fromResponse(event.benchmark, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw })
|
||||||
|
) { it.name }
|
||||||
|
if(idx != null) {
|
||||||
|
if(idx < _currentBenchmark.value) _currentBenchmark.value += 1
|
||||||
|
}
|
||||||
|
_benchmarks.value = updated
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,24 +224,102 @@ class ProjectVM(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mkEntry(label: Version, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit, input: String, hardware: String) {
|
fun mkBenchmark(name: String) {
|
||||||
withScope {
|
withScope {
|
||||||
_repo.newEntry(label.id, Clock.System.now(), warmups, measurements, unit, input, hardware).snackOr {
|
_repo.newBenchmark(name).snackOr { bench ->
|
||||||
if(_job == null) {
|
if(_job == null) {
|
||||||
_entries.value += Entry.fromResponse(it, _labels.value)
|
val (updated, idx) = _benchmarks.value.insortIdx(
|
||||||
_inputs.value += input
|
Benchmark.fromResponse(bench, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw })
|
||||||
_hardware.value += hardware
|
) { it.name }
|
||||||
|
_currentBenchmark.value.let { curr ->
|
||||||
|
if(idx != null) {
|
||||||
|
if(idx < curr) _currentBenchmark.value = curr + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_benchmarks.value = updated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteEntry(id: Uuid) {
|
fun updateBenchmark(id: Uuid, name: String?) {
|
||||||
withScope {
|
withScope {
|
||||||
_repo.deleteEntry(id).snackOr {
|
val old = _benchmarks.value.find { it.id == id } ?: return@withScope
|
||||||
if(_job == null)
|
_repo.updateBenchmark(id, name).snackOr {
|
||||||
_entries.value = _entries.value.filter { it.id != id }
|
if(_job == null) {
|
||||||
|
_benchmarks.value = _benchmarks.value.replace({ it.id == id }) { it.copy(name = name ?: old.name) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteBenchmark(id: Uuid) {
|
||||||
|
withScope {
|
||||||
|
_repo.deleteBenchmark(id).snackOr {
|
||||||
|
if(_job == null) {
|
||||||
|
val idx = _benchmarks.value.indexOfFirst { it.id == id }
|
||||||
|
if(idx == _currentBenchmark.value) _currentBenchmark.value = 0
|
||||||
|
else if(idx < _currentBenchmark.value) _currentBenchmark.value -= 1
|
||||||
|
_benchmarks.value = _benchmarks.value.filter { it.id != id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mkEntry(label: Version, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit, input: String, hardware: String) {
|
||||||
|
withScope {
|
||||||
|
val current = _benchmarks.value.getOrNull(_currentBenchmark.value) ?: return@withScope
|
||||||
|
_repo.newEntry(current.id, label.id, Clock.System.now(), warmups, measurements, unit, input, hardware).snackOr { entry ->
|
||||||
|
if(_job == null) {
|
||||||
|
_benchmarks.value = _benchmarks.value.replace({ it.id == entry.benchmarkId }) { it.add(Entry.fromResponse(entry, _labels.value,
|
||||||
|
{ input -> _inputs.value += input }, { hw -> _hardware.value += hw })) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun previousBenchmark() {
|
||||||
|
_currentBenchmark.value = maxOf(0, minOf(_currentBenchmark.value - 1, _benchmarks.value.size - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nextBenchmark() {
|
||||||
|
_currentBenchmark.value = maxOf(0, minOf(_currentBenchmark.value + 1, _benchmarks.value.size - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectBenchmark(id: Uuid) {
|
||||||
|
_currentBenchmark.value = maxOf(_benchmarks.value.indexOfFirst { it.id == id }, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openMonitor() {
|
||||||
|
if(_monitorJob != null) return
|
||||||
|
_monitorMessages.value = listOf()
|
||||||
|
_monitorJob = withScope {
|
||||||
|
_sseRepo.connectMonitor(_id) { ev ->
|
||||||
|
ev.snackOr {
|
||||||
|
when(it) {
|
||||||
|
is ServerMonitorEvent.Backlog -> {
|
||||||
|
val res = ArrayList<ServerMonitorEvent.ITextEvent>(it.messages.size + 2)
|
||||||
|
it.start?.let { start -> res += start }
|
||||||
|
res.addAll(it.messages)
|
||||||
|
it.end?.let { end -> res += end }
|
||||||
|
_monitorMessages.value = res
|
||||||
|
}
|
||||||
|
ServerMonitorEvent.Cleared -> _monitorMessages.value = listOf()
|
||||||
|
is ServerMonitorEvent.ApplicationEnd, is ServerMonitorEvent.ApplicationStart, is ServerMonitorEvent.Message ->
|
||||||
|
_monitorMessages.value = (_monitorMessages.value ?: listOf()) + it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeMonitor() {
|
||||||
|
_monitorJob?.cancel()
|
||||||
|
_monitorJob = null
|
||||||
|
_monitorMessages.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearMonitor() = withScope {
|
||||||
|
_repo.deleteMonitorLogs().snackOnError()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
package com.jaytux.phoebench.clients.theme
|
package com.jaytux.phoebench.clients.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
val linkColor = Color(0xFF64B5F6)
|
val linkColor = Color(0xFF64B5F6)
|
||||||
|
|
||||||
|
enum class UiMode(val minWidth: Dp) {
|
||||||
|
MOBILE(0.dp),
|
||||||
|
NARROW(0.dp),
|
||||||
|
WIDE(2000.dp);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromDp(width: Dp) = when {
|
||||||
|
width < NARROW.minWidth -> MOBILE
|
||||||
|
width < WIDE.minWidth -> NARROW
|
||||||
|
else -> WIDE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val GlobalUiMode = compositionLocalOf { UiMode.MOBILE }
|
||||||
@@ -25,6 +25,7 @@ import androidx.compose.material3.DropdownMenuItem
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -181,7 +182,6 @@ fun LoginView(server: String?, error: String?, onSwitchSignup: () -> Unit, onLog
|
|||||||
Text("Log in", style = MaterialTheme.typography.headlineMedium)
|
Text("Log in", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
|
||||||
ServerSelect(serverUrl) { serverUrl = it }
|
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)
|
||||||
@@ -213,7 +213,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)
|
||||||
@@ -238,10 +238,12 @@ fun SignupView(server: String?, error: String?, onSwitchLogin: () -> Unit, onSig
|
|||||||
@Composable
|
@Composable
|
||||||
fun AuthenticatedRoot() {
|
fun AuthenticatedRoot() {
|
||||||
val snacks = SnackProvider.get()
|
val snacks = SnackProvider.get()
|
||||||
|
val auth = AuthProvider.get()
|
||||||
val state = remember { SnackbarHostState() }
|
val state = remember { SnackbarHostState() }
|
||||||
val vm = viewModel { HomeVM() }
|
val vm = viewModel { HomeVM() }
|
||||||
var loggingOut by remember { mutableStateOf(false) }
|
var loggingOut by remember { mutableStateOf(false) }
|
||||||
var currentProject by remember { mutableStateOf<Uuid?>(null) }
|
var currentProject by remember { mutableStateOf<Uuid?>(null) }
|
||||||
|
val server by auth.server
|
||||||
|
|
||||||
LaunchedEffect(snacks) {
|
LaunchedEffect(snacks) {
|
||||||
snacks.snacks.collect { state.showSnackbar(it) }
|
snacks.snacks.collect { state.showSnackbar(it) }
|
||||||
@@ -255,7 +257,12 @@ fun AuthenticatedRoot() {
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("PhoeBench v${ClientVersion.VERSION}", style = MaterialTheme.typography.headlineLarge) },
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("PhoeBench v${ClientVersion.VERSION}", style = MaterialTheme.typography.headlineLarge)
|
||||||
|
server?.let { Text("Connected to $it", style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(0.6f)) }
|
||||||
|
}
|
||||||
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
@@ -321,7 +328,19 @@ fun ConfirmLogoutDialog(onCancel: () -> Unit, onLogout: (everywhere: Boolean) ->
|
|||||||
@Composable
|
@Composable
|
||||||
fun HomeView(onSelectProject: (Uuid) -> Unit) {
|
fun HomeView(onSelectProject: (Uuid) -> Unit) {
|
||||||
val vm = viewModel { HomeVM() }
|
val vm = viewModel { HomeVM() }
|
||||||
|
val todo by vm.stepsNeeded
|
||||||
|
|
||||||
|
todo.firstOrNull()?.let {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
when (it) {
|
||||||
|
is HomeResponse.NeededMigration.Migrate0To1 -> Migrate0To1View(vm, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: HomePanel(vm, onSelectProject)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomePanel(vm: HomeVM, onSelectProject: (Uuid) -> Unit) {
|
||||||
val auth = AuthProvider.get()
|
val auth = AuthProvider.get()
|
||||||
val username by vm.username
|
val username by vm.username
|
||||||
val isAdmin by vm.isAdmin
|
val isAdmin by vm.isAdmin
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package com.jaytux.phoebench.clients.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.draganddrop.dragAndDropSource
|
||||||
|
import androidx.compose.foundation.draganddrop.dragAndDropTarget
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import com.composables.icons.lucide.Cross
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.Plus
|
||||||
|
import com.composables.icons.lucide.X
|
||||||
|
import com.jaytux.phoebench.clients.app
|
||||||
|
import com.jaytux.phoebench.clients.data.HomeVM
|
||||||
|
import com.jaytux.phoebench.clients.drag
|
||||||
|
import com.jaytux.phoebench.clients.drop
|
||||||
|
import com.jaytux.phoebench.clients.removeAt
|
||||||
|
import com.jaytux.phoebench.clients.replace
|
||||||
|
import com.jaytux.phoebench.common.HomeResponse
|
||||||
|
import com.jaytux.phoebench.common.Migrate0To1Request
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Migrate0To1View(vm: HomeVM, input: HomeResponse.NeededMigration.Migrate0To1) {
|
||||||
|
var isAdding by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var projectsToDo by remember { mutableStateOf(input.projects) }
|
||||||
|
var remapped by remember { mutableStateOf(listOf<Triple<String, Boolean, List<HomeResponse.NeededMigration.Migrate0To1.OldProject>>>()) }
|
||||||
|
|
||||||
|
Surface(Modifier.fillMaxSize(0.85f), color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 10.dp, shape = MaterialTheme.shapes.large) {
|
||||||
|
Column(Modifier.padding(15.dp)) {
|
||||||
|
Text("Data Migration", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Text("The server you connected to has been updated, requiring some decisions from you.")
|
||||||
|
Text("This new version allows you to organise different benchmarks together in one project. That way, they will share installed versions.")
|
||||||
|
Text("Please create the new projects you would like to use, and drag the existing projects onto them as benchmarks. You can also drag a benchmark onto an empty space to have it become a top-level project containing a benchmark with the same name.")
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Row(Modifier.weight(1f)) {
|
||||||
|
var showTargets by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val globalTarget = remember {
|
||||||
|
object : DragAndDropTarget {
|
||||||
|
override fun onStarted(event: DragAndDropEvent) {
|
||||||
|
showTargets = true
|
||||||
|
super.onStarted(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEnded(event: DragAndDropEvent) {
|
||||||
|
showTargets = false
|
||||||
|
super.onEnded(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||||
|
val got = drop<HomeResponse.NeededMigration.Migrate0To1.OldProject>(event) ?: return false
|
||||||
|
|
||||||
|
println("DROP ONTO global")
|
||||||
|
projectsToDo = projectsToDo.filter { it.id != got.id }
|
||||||
|
remapped += got.name to false app listOf(got)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val startMod = if(showTargets) Modifier.border(BorderStroke(3.dp, MaterialTheme.colorScheme.onSurface)) else Modifier
|
||||||
|
|
||||||
|
Surface(startMod.weight(0.66f).fillMaxHeight().dragAndDropTarget({ true }, globalTarget), tonalElevation = 10.dp, shape = MaterialTheme.shapes.large) {
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Text("New projects", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
|
||||||
|
LazyVerticalGrid(GridCells.Adaptive(minSize = 350.dp), Modifier.weight(1f)) {
|
||||||
|
itemsIndexed(remapped) { idx, (name, public, old) ->
|
||||||
|
val target = remember {
|
||||||
|
object : DragAndDropTarget {
|
||||||
|
override fun onStarted(event: DragAndDropEvent) {
|
||||||
|
showTargets = true
|
||||||
|
super.onStarted(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEnded(event: DragAndDropEvent) {
|
||||||
|
showTargets = false
|
||||||
|
super.onEnded(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||||
|
val got = drop<HomeResponse.NeededMigration.Migrate0To1.OldProject>(event) ?: return false
|
||||||
|
|
||||||
|
println("DROP ONTO $name")
|
||||||
|
projectsToDo = projectsToDo.filter { it.id != got.id }
|
||||||
|
remapped = remapped.replace({ it.first == name }) { it.first to it.second app (it.third + got) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val toggle = { public: Boolean ->
|
||||||
|
remapped = remapped.replace({ it.first == name }) { it.first to public app it.third }
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(startMod.padding(5.dp).dragAndDropTarget({ true }, target), color = MaterialTheme.colorScheme.tertiaryContainer, tonalElevation = 4.dp, shadowElevation = 1.dp, shape = MaterialTheme.shapes.medium) {
|
||||||
|
Row(Modifier.padding(5.dp).width(IntrinsicSize.Min)) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(name)
|
||||||
|
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||||
|
SegmentedButton(!public, { toggle(false) }, SegmentedButtonDefaults.itemShape(0, 2)) { Text("Private") }
|
||||||
|
SegmentedButton(public, { toggle(true) }, SegmentedButtonDefaults.itemShape(1, 2)) { Text("Public") }
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(start = 5.dp)) {
|
||||||
|
old.forEach { Text(it.name, style = MaterialTheme.typography.bodySmall) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton({ projectsToDo += old; remapped = remapped.removeAt(idx) }) {
|
||||||
|
Icon(Lucide.X, "Remove")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(remapped.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Surface(startMod.padding(5.dp).dragAndDropTarget({ true }, globalTarget), color = MaterialTheme.colorScheme.tertiaryContainer, tonalElevation = 4.dp, shadowElevation = 1.dp, shape = MaterialTheme.shapes.medium) {
|
||||||
|
Text("Create new...", Modifier.padding(vertical = 15.dp), fontStyle = FontStyle.Italic, color = LocalContentColor.current.copy(alpha = 0.66f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Row {
|
||||||
|
Box(Modifier.weight(1f))
|
||||||
|
IconButton({ isAdding = true }) {
|
||||||
|
Icon(Lucide.Plus, "Add project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
|
||||||
|
Surface(Modifier.weight(0.33f).fillMaxHeight(), tonalElevation = 10.dp, shape = MaterialTheme.shapes.large) {
|
||||||
|
LazyColumn(Modifier.padding(10.dp).fillMaxHeight()) {
|
||||||
|
stickyHeader {
|
||||||
|
Text("Existing projects", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
}
|
||||||
|
items(projectsToDo) {
|
||||||
|
Column {
|
||||||
|
Surface(Modifier.padding(5.dp).fillMaxWidth().dragAndDropSource { _ -> drag(it) }, tonalElevation = 15.dp, shape = MaterialTheme.shapes.medium) {
|
||||||
|
Text("${it.name} (${it.versions.size} versions)", Modifier.padding(10.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Row {
|
||||||
|
Box(Modifier.weight(1f))
|
||||||
|
|
||||||
|
Button({ vm.migrate(remapped.map { (name, public, old) -> Migrate0To1Request.MigrateProject(name, public, old.map { it.id }) }) },
|
||||||
|
enabled = projectsToDo.isEmpty()) {
|
||||||
|
Text("Perform migration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isAdding) AddProjectDialog({ isAdding = false }) { name, public ->
|
||||||
|
remapped += name to public app listOf()
|
||||||
|
isAdding = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddProjectDialog(onCancel: () -> Unit, onAdd: (name: String, public: Boolean) -> Unit) {
|
||||||
|
Dialog(onDismissRequest = onCancel) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var public by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
|
||||||
|
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
|
||||||
|
Text("Create new project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||||
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||||
|
SegmentedButton(!public, { public = false }, SegmentedButtonDefaults.itemShape(0, 2)) { Text("Private") }
|
||||||
|
SegmentedButton(public, { public = true }, SegmentedButtonDefaults.itemShape(1, 2)) { Text("Public") }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
CancelConfirmXRow(onCancel, {
|
||||||
|
onAdd(name.trim(), public)
|
||||||
|
}, confirmText = "Create", canConfirm = name.trim().isNotBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
package com.jaytux.phoebench.clients.ui
|
||||||
|
|
||||||
|
import androidx.annotation.FloatRange
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.LocalIndication
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.contentColorFor
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.*
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Outline
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.PanelRightClose
|
||||||
|
import com.composables.icons.lucide.PanelRightOpen
|
||||||
|
import com.jaytux.phoebench.clients.app
|
||||||
|
|
||||||
|
@DslMarker annotation class NarrowDslMarker
|
||||||
|
@DslMarker annotation class TabPanelDslMarker
|
||||||
|
|
||||||
|
@NarrowDslMarker
|
||||||
|
interface INarrowDsl {
|
||||||
|
fun mainContent(content: @Composable ((isNarrow: Boolean, toggleSide: () -> Unit) -> Unit))
|
||||||
|
fun sideContent(onSurface: Boolean, content: @Composable (isNarrow: Boolean, toggleSide: () -> Unit) -> Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class NarrowDslImpl : INarrowDsl {
|
||||||
|
var main: @Composable (isNarrow: Boolean, toggleSide: () -> Unit) -> Unit = { _, _ -> }
|
||||||
|
private set
|
||||||
|
var side: @Composable (isNarrow: Boolean, toggleSide: () -> Unit) -> Unit = { _, _ -> }
|
||||||
|
private set
|
||||||
|
var sideSurface: Boolean = true
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun mainContent(content: @Composable ((isNarrow: Boolean, toggleSide: () -> Unit) -> Unit)) { main = content }
|
||||||
|
override fun sideContent(onSurface: Boolean, content: @Composable ((isNarrow: Boolean, toggleSide: () -> Unit) -> Unit)) { sideSurface = onSurface; side = content }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RowOrPanel(useRow: Boolean, modifier: Modifier = Modifier, @FloatRange(0.0, 1.0) defaultWeight: Float = 0.5f, builder: INarrowDsl.() -> Unit) {
|
||||||
|
val content = NarrowDslImpl().also { it.builder() }
|
||||||
|
|
||||||
|
if(!useRow) {
|
||||||
|
var isOpen by remember { mutableStateOf(false) }
|
||||||
|
Box(modifier) {
|
||||||
|
content.main(true) { isOpen = !isOpen }
|
||||||
|
if(isOpen) {
|
||||||
|
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
|
||||||
|
Box(Modifier.matchParentSize().background(Color.Black.copy(alpha = 0.5f)).clickable { isOpen = false })
|
||||||
|
}
|
||||||
|
val shape = MaterialTheme.shapes.medium.copy(topEnd = CornerSize(0.dp), bottomEnd = CornerSize(0.dp))
|
||||||
|
Box(Modifier.matchParentSize()) {
|
||||||
|
if(content.sideSurface) {
|
||||||
|
Surface(Modifier.align(Alignment.CenterEnd).fillMaxHeight().fillMaxWidth(0.9f), shape = shape, shadowElevation = 3.dp) {
|
||||||
|
Box(Modifier.padding(10.dp)) {
|
||||||
|
content.side(true) { isOpen = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Box(Modifier.align(Alignment.CenterEnd)) {
|
||||||
|
content.side(true) { isOpen = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Row(modifier) {
|
||||||
|
Box(Modifier.weight(defaultWeight)) { content.main(false) {} }
|
||||||
|
Box(Modifier.weight(1.0f - defaultWeight)) { content.side(false) {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TabPanelShape(val tabWidth: Dp, val tabHeight: Dp = tabWidth, val cornerRadius: CornerSize) : Shape {
|
||||||
|
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
|
||||||
|
val (tabWpx, tabHpx, rpx) = with(density) {
|
||||||
|
tabWidth.toPx() to tabHeight.toPx() app cornerRadius.toPx(size, density)
|
||||||
|
}
|
||||||
|
|
||||||
|
val radius = CornerRadius(rpx, rpx)
|
||||||
|
|
||||||
|
val shape = Path().apply {
|
||||||
|
addRoundRect(RoundRect(
|
||||||
|
Rect(offset = Offset(x = tabWpx, y = 0f), size = Size(width = size.width - tabWpx, height = size.height)),
|
||||||
|
topLeft = CornerRadius.Zero, topRight = CornerRadius.Zero,
|
||||||
|
bottomLeft = radius, bottomRight = CornerRadius.Zero
|
||||||
|
))
|
||||||
|
|
||||||
|
addRoundRect(RoundRect(
|
||||||
|
Rect(offset = Offset(x = 0f, y = 0f), size = Size(size.width, tabHpx)),
|
||||||
|
topLeft = radius, topRight = CornerRadius.Zero,
|
||||||
|
bottomLeft = radius, bottomRight = CornerRadius.Zero
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return Outline.Generic(shape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TabPanel(tabWidth: Dp, tabHeight: Dp = tabWidth, modifier: Modifier = Modifier,
|
||||||
|
cornerRadius: CornerSize = MaterialTheme.shapes.medium.topEnd,
|
||||||
|
color: Color = MaterialTheme.colorScheme.surface, contentColor: Color = contentColorFor(color),
|
||||||
|
tonalElevation: Dp = 0.dp, shadowElevation: Dp = 0.dp, border: BorderStroke? = null,
|
||||||
|
tabContent: @Composable () -> Unit,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(modifier,
|
||||||
|
shape = TabPanelShape(tabWidth, tabHeight, cornerRadius), color = color, contentColor = contentColor,
|
||||||
|
tonalElevation = tonalElevation, shadowElevation = shadowElevation, border = border
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.size(width = tabWidth, height = tabHeight), propagateMinConstraints = true) { tabContent() }
|
||||||
|
Box(Modifier.padding(start = tabWidth)) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TabPanel(tabWidth: Dp, modifier: Modifier, tabHeight: Dp = tabWidth,
|
||||||
|
cornerRadius: CornerSize = MaterialTheme.shapes.medium.topEnd,
|
||||||
|
color: Color = MaterialTheme.colorScheme.surface, contentColor: Color = contentColorFor(color),
|
||||||
|
tonalElevation: Dp = 0.dp, shadowElevation: Dp = 0.dp, border: BorderStroke? = null,
|
||||||
|
tabContent: @Composable () -> Unit, content: @Composable () -> Unit) =
|
||||||
|
TabPanel(tabWidth, tabHeight, modifier, cornerRadius, color, contentColor, tonalElevation, shadowElevation, border, tabContent, content)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExpandableSidePanel(
|
||||||
|
modifier: Modifier = Modifier, tabWidth: Dp = 40.dp, tabHeight: Dp = tabWidth,
|
||||||
|
radius: CornerSize = MaterialTheme.shapes.medium.topEnd, elevation: Dp = 5.dp,
|
||||||
|
color: Color = MaterialTheme.colorScheme.surface, toggleSide: () -> Unit
|
||||||
|
) = TabPanel(tabWidth, tabHeight, modifier, radius, color, shadowElevation = elevation, tonalElevation = elevation,
|
||||||
|
tabContent = {
|
||||||
|
Surface(Modifier.padding(5.dp), shape = MaterialTheme.shapes.medium) {
|
||||||
|
Icon(Lucide.PanelRightOpen, "Open side panel",
|
||||||
|
Modifier.aspectRatio(1f).fillMaxSize(0.66f).clickable { toggleSide() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExpandedSidePanel(
|
||||||
|
modifier: Modifier = Modifier, tabWidth: Dp = 40.dp, tabHeight: Dp = tabWidth,
|
||||||
|
radius: CornerSize = MaterialTheme.shapes.medium.topEnd, elevation: Dp = 5.dp,
|
||||||
|
color: Color = MaterialTheme.colorScheme.surface, toggleSide: () -> Unit, content: @Composable () -> Unit
|
||||||
|
) = TabPanel(tabWidth, tabHeight, modifier, radius, color, shadowElevation = elevation, tonalElevation = elevation,
|
||||||
|
tabContent = {
|
||||||
|
Surface(Modifier.padding(5.dp), shape = MaterialTheme.shapes.medium) {
|
||||||
|
Icon(Lucide.PanelRightClose, "Close side panel",
|
||||||
|
Modifier.aspectRatio(1f).fillMaxSize(0.66f).clickable { toggleSide() })
|
||||||
|
}
|
||||||
|
}, content = content
|
||||||
|
)
|
||||||
+577
-119
@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -18,17 +19,23 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalTextToolbar
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
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.*
|
||||||
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.theme.GlobalUiMode
|
||||||
|
import com.jaytux.phoebench.clients.theme.UiMode
|
||||||
|
import com.jaytux.phoebench.clients.theme.linkColor
|
||||||
import com.jaytux.phoebench.common.*
|
import com.jaytux.phoebench.common.*
|
||||||
import com.kborowy.colorpicker.KolorPicker
|
import com.kborowy.colorpicker.KolorPicker
|
||||||
import io.github.koalaplot.core.bar.DefaultBar
|
import io.github.koalaplot.core.bar.DefaultBar
|
||||||
@@ -50,18 +57,19 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
|
|||||||
val owner by vm.owner
|
val owner by vm.owner
|
||||||
val public by vm.public
|
val public by vm.public
|
||||||
val editable by vm.editable
|
val editable by vm.editable
|
||||||
val labels by vm.versions
|
val versions by vm.versions
|
||||||
|
val benchmarks by vm.benchmarks
|
||||||
|
val currentBenchmark by vm.currentBenchmark
|
||||||
|
val monitor by vm.monitorMessages
|
||||||
|
|
||||||
var editing by remember { mutableStateOf(false) }
|
var editing by remember { mutableStateOf(false) }
|
||||||
var deleting by remember { mutableStateOf(false) }
|
var deleting by remember { mutableStateOf(false) }
|
||||||
var addOpen by remember { mutableStateOf(false) }
|
var addingBenchmark by remember { mutableStateOf(false) }
|
||||||
var addingLabel by remember { mutableStateOf(false) }
|
|
||||||
var addingData by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Column(Modifier.padding(all = 15.dp)) {
|
Column(Modifier.padding(all = 15.dp)) {
|
||||||
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text("Project ${name ?: "Unnamed Project"}", style = MaterialTheme.typography.headlineMedium)
|
Text("Project ${name ?: "Unnamed Project"}", style = MaterialTheme.typography.headlineMedium)
|
||||||
if(editable) {
|
if (editable) {
|
||||||
Spacer(Modifier.width(25.dp))
|
Spacer(Modifier.width(25.dp))
|
||||||
IconButton({ editing = true }) {
|
IconButton({ editing = true }) {
|
||||||
Icon(Lucide.Pencil, "Edit project details")
|
Icon(Lucide.Pencil, "Edit project details")
|
||||||
@@ -69,27 +77,217 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
|
|||||||
IconButton({ deleting = true }) {
|
IconButton({ deleting = true }) {
|
||||||
Icon(Lucide.Trash, "Delete project")
|
Icon(Lucide.Trash, "Delete project")
|
||||||
}
|
}
|
||||||
|
IconButton({ vm.openMonitor() }) {
|
||||||
|
Icon(Lucide.SquareTerminal, "Open monitor")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
owner?.let { Text("${if(public) "Public" else "Private"} project by user $it") }
|
owner?.let { Text("${if(public) "Public" else "Private"} project by user $it") }
|
||||||
Spacer(Modifier.height(15.dp))
|
Spacer(Modifier.height(15.dp))
|
||||||
Surface(Modifier.fillMaxSize().padding(8.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) {
|
benchmarks.getOrNull(currentBenchmark)?.let { curr ->
|
||||||
Box(Modifier.fillMaxSize().padding(15.dp)) {
|
ProjectPanel(vm, editable, versions, benchmarks, currentBenchmark, curr) { addingBenchmark = true }
|
||||||
ProjectPlotArea(vm)
|
} ?: run {
|
||||||
|
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Text("No benchmarks yet...", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
|
||||||
|
|
||||||
|
if(addingBenchmark) {
|
||||||
|
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
|
||||||
|
Box(Modifier.clickable { addingBenchmark = false }.fillMaxSize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.align(Alignment.BottomEnd).padding(25.dp)) {
|
||||||
|
IconButton({ addingBenchmark = true }, Modifier.scale(1.5f), shape = MaterialTheme.shapes.medium) {
|
||||||
|
Icon(Lucide.Plus, "Add benchmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(editing) ProjectEditDialog(name ?: "Unnamed Project", public, { editing = false }) { name, public ->
|
||||||
|
vm.update(name, public)
|
||||||
|
}
|
||||||
|
|
||||||
|
if(deleting) ConfirmDeleteProjectDialog(name ?: "Unnamed Project", { deleting = false }) {
|
||||||
|
vm.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
if(addingBenchmark) AddBenchmarkDialog({ addingBenchmark = false }) {
|
||||||
|
vm.mkBenchmark(it)
|
||||||
|
addingBenchmark = false
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor?.let { MonitorDialog(name ?: "Unnamed Project", it, vm::closeMonitor, vm::clearMonitor) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MonitorMessage(msg: ServerMonitorEvent.ITextEvent) = when(msg) {
|
||||||
|
is ServerMonitorEvent.ApplicationEnd -> Text("[${msg.time.fmt()}] Application finished with exit code ${msg.exitCode}.", fontStyle = FontStyle.Italic)
|
||||||
|
is ServerMonitorEvent.ApplicationStart -> Text("[${msg.time.fmt()}] Application started.", fontStyle = FontStyle.Italic)
|
||||||
|
is ServerMonitorEvent.Message -> {
|
||||||
|
val ansi = ANSI.unpack(msg.options).toSet()
|
||||||
|
|
||||||
|
val weight = if(ANSI.BOLD in ansi) FontWeight.Bold else null
|
||||||
|
val decoration = if(ANSI.UNDERLINE in ansi) TextDecoration.Underline else null
|
||||||
|
val color = when {
|
||||||
|
ANSI.FG_RED in ansi -> Color.Red
|
||||||
|
ANSI.FG_GREEN in ansi -> Color.Green
|
||||||
|
ANSI.FG_YELLOW in ansi -> Color.Yellow
|
||||||
|
ANSI.FG_BLUE in ansi -> Color.Blue
|
||||||
|
ANSI.FG_GRAY in ansi -> Color.Gray
|
||||||
|
else -> LocalContentColor.current
|
||||||
|
}
|
||||||
|
|
||||||
|
val background = when {
|
||||||
|
ANSI.BG_RED in ansi -> Color.Red
|
||||||
|
ANSI.BG_GREEN in ansi -> Color.Green
|
||||||
|
ANSI.BG_YELLOW in ansi -> Color.Yellow
|
||||||
|
ANSI.BG_BLUE in ansi -> Color.Blue
|
||||||
|
ANSI.BG_GRAY in ansi -> Color.Gray
|
||||||
|
else -> null
|
||||||
|
}?.let { Modifier.background(it) } ?: Modifier
|
||||||
|
|
||||||
|
Box(background.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
"[${msg.timeStamp.fmt()}] [${if (msg.stream == Stream.STDOUT) 'O' else 'E'}] ${msg.message}",
|
||||||
|
fontWeight = weight, textDecoration = decoration, color = color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MonitorDialog(name: String, logs: List<ServerMonitorEvent.ITextEvent>, onClose: () -> Unit, onClear: () -> Unit) {
|
||||||
|
var autoFollow by remember { mutableStateOf(true) }
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(autoFollow, logs) {
|
||||||
|
if(autoFollow) {
|
||||||
|
state.animateScrollToItem(logs.size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||||
|
Surface(Modifier.padding(15.dp).fillMaxHeight(0.8f).widthIn(min = 300.dp, max = 2000.dp), shape = MaterialTheme.shapes.medium) {
|
||||||
|
Column(Modifier.padding(8.dp)) {
|
||||||
|
Text("Monitor for $name", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
if(logs.isEmpty()) {
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
Box(Modifier.fillMaxHeight(0.33f).fillMaxWidth()) {
|
||||||
|
Text("No monitor logs.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
CompositionLocalProvider(LocalTextStyle provides LocalTextStyle.current.makeMonospaced()) {
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
LazyColumn(Modifier.fillMaxHeight().fillMaxWidth().padding(10.dp).background(MaterialTheme.colorScheme.surfaceDim), state) {
|
||||||
|
items(logs) {
|
||||||
|
MonitorMessage(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.align(Alignment.TopEnd).padding(25.dp)) {
|
||||||
|
val colors = if(autoFollow)
|
||||||
|
IconButtonDefaults.iconButtonColors(containerColor = LocalContentColor.current, contentColor = MaterialTheme.colorScheme.surface)
|
||||||
|
else IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.surface, contentColor = LocalContentColor.current)
|
||||||
|
IconButton({ autoFollow = !autoFollow }, colors = colors) {
|
||||||
|
Icon(Lucide.ArrowDownToLine, "Follow")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Row {
|
||||||
|
Button(onClear, Modifier.weight(0.5f)) { Text("Clear Monitor") }
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Button(onClose, Modifier.weight(0.5f)) { Text("Close Monitor") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM.Version>, benchmarks: List<ProjectVM.Benchmark>, currentIndex: Int, currentBenchmark: ProjectVM.Benchmark, onAddBenchmark: () -> Unit) {
|
||||||
|
var addOpen by remember { mutableStateOf(false) }
|
||||||
|
var addingLabel by remember { mutableStateOf(false) }
|
||||||
|
var addingData by remember { mutableStateOf(false) }
|
||||||
|
var editing by remember { mutableStateOf(false) }
|
||||||
|
var deleting by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Surface(Modifier.fillMaxSize().padding(8.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Surface(Modifier.fillMaxWidth(), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) {
|
||||||
|
Row(Modifier.padding(15.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton({ vm.previousBenchmark() }, enabled = currentIndex > 0) {
|
||||||
|
Icon(Lucide.ChevronLeft, "Previous benchmark")
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
var isOpen by remember { mutableStateOf(false) }
|
||||||
|
Surface(shape = MaterialTheme.shapes.medium) {
|
||||||
|
Row(Modifier.clickable { isOpen = !isOpen }, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(currentBenchmark.name, style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
IconButton({ editing = true }) {
|
||||||
|
Icon(Lucide.Pencil, "Edit benchmark")
|
||||||
|
}
|
||||||
|
IconButton({ deleting = true }) {
|
||||||
|
Icon(Lucide.Trash, "Remove benchmark")
|
||||||
|
}
|
||||||
|
Icon(Lucide.ChevronDown, "Select benchmark...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(isOpen, { isOpen = false }) {
|
||||||
|
benchmarks.forEach { b ->
|
||||||
|
DropdownMenuItem({ Text(b.name, style = MaterialTheme.typography.headlineSmall) }, { vm.selectBenchmark(b.id); isOpen = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton({ vm.nextBenchmark() }, enabled = currentIndex < benchmarks.size - 1) {
|
||||||
|
Icon(Lucide.ChevronRight, "Previous benchmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
ProjectPlotArea(vm, currentBenchmark)
|
||||||
|
|
||||||
|
if(editable) {
|
||||||
if(addOpen) {
|
if(addOpen) {
|
||||||
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
|
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
|
||||||
Box(Modifier.clickable { addOpen = false }.fillMaxSize())
|
Box(Modifier.clickable { addOpen = false }.fillMaxSize())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.align(Alignment.BottomEnd).padding(25.dp), horizontalAlignment = Alignment.End) {
|
Column(Modifier.align(Alignment.BottomEnd).padding(40.dp), horizontalAlignment = Alignment.End) {
|
||||||
if(addOpen) {
|
if (addOpen) {
|
||||||
var lblWidth by mutableStateOf(1000.dp)
|
var lblWidth by mutableStateOf(1000.dp)
|
||||||
var dataWidth by mutableStateOf(1000.dp)
|
var dataWidth by mutableStateOf(1000.dp)
|
||||||
|
var benchWidth by mutableStateOf(1000.dp)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
Column(Modifier.width(maxOf(lblWidth, dataWidth)), horizontalAlignment = Alignment.End) {
|
Column(Modifier.width(maxOf(lblWidth, dataWidth, benchWidth)), horizontalAlignment = Alignment.End) {
|
||||||
|
Surface(Modifier.onGloballyPositioned {
|
||||||
|
benchWidth = with(density) { it.size.width.toDp() }
|
||||||
|
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
|
||||||
|
Row(Modifier.clickable(onClick = onAddBenchmark).padding(15.dp)) {
|
||||||
|
Icon(Lucide.TestTubeDiagonal, "Add benchmark")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("Benchmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(15.dp))
|
||||||
|
|
||||||
Surface(Modifier.onGloballyPositioned {
|
Surface(Modifier.onGloballyPositioned {
|
||||||
lblWidth = with(density) { it.size.width.toDp() }
|
lblWidth = with(density) { it.size.width.toDp() }
|
||||||
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
|
}, shape = MaterialTheme.shapes.small, shadowElevation = 10.dp) {
|
||||||
@@ -117,19 +315,12 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
IconButton({ addOpen = !addOpen }, Modifier.scale(1.5f), shape = MaterialTheme.shapes.medium) {
|
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")
|
Icon(if (addOpen) Lucide.X else Lucide.Plus, if (addOpen) "Add data" else "Close menu")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(editing) ProjectEditDialog(name ?: "Unnamed Project", public, { editing = false }) { name, public ->
|
|
||||||
vm.update(name, public)
|
|
||||||
}
|
|
||||||
|
|
||||||
if(deleting) ConfirmDeleteProjectDialog(name ?: "Unnamed Project", { deleting = false }) {
|
|
||||||
vm.delete()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if(addingLabel) AddVersionDialog({ addingLabel = false; addOpen = false }) { name, color, meta ->
|
if(addingLabel) AddVersionDialog({ addingLabel = false; addOpen = false }) { name, color, meta ->
|
||||||
@@ -140,13 +331,17 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
|
|||||||
val usedInputs by vm.inputs
|
val usedInputs by vm.inputs
|
||||||
val usedHardware by vm.hardware
|
val usedHardware by vm.hardware
|
||||||
|
|
||||||
if(addingData) AddEntryDialog(labels, usedInputs, usedHardware,
|
if(addingData) AddEntryDialog(versions, usedInputs, usedHardware,
|
||||||
onCancel = { addingData = false; addOpen = false },
|
onCancel = { addingData = false; addOpen = false },
|
||||||
onAddLbl = { name, lbl, meta -> vm.mkLabel(name, lbl, meta) }
|
onAddLbl = { name, lbl, meta -> vm.mkLabel(name, lbl, meta) }
|
||||||
) { label, warmups, measurements, unit, input, hardware ->
|
) { label, warmups, measurements, unit, input, hardware ->
|
||||||
vm.mkEntry(label, warmups, measurements, unit, input, hardware)
|
vm.mkEntry(label, warmups, measurements, unit, input, hardware)
|
||||||
addOpen = false
|
addOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(editing) EditBenchmarkDialog(currentBenchmark, { editing = false }) { vm.updateBenchmark(currentBenchmark.id, it) }
|
||||||
|
|
||||||
|
if(deleting) ConfirmDeleteBenchmarkDialog(currentBenchmark, { deleting = false }) { vm.deleteBenchmark(currentBenchmark.id); deleting = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -157,7 +352,7 @@ fun ProjectEditDialog(name: String, public: Boolean, onCancel: () -> Unit, onSav
|
|||||||
|
|
||||||
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
|
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
|
||||||
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
|
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
|
||||||
Text("Create project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
Text("Modify project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||||
OutlinedTextField(updName, { updName = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
OutlinedTextField(updName, { updName = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Row(Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -191,6 +386,59 @@ fun ConfirmDeleteProjectDialog(name: String, onCancel: () -> Unit, onDelete: ()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConfirmDeleteBenchmarkDialog(benchmark: ProjectVM.Benchmark, onCancel: () -> Unit, onDelete: () -> Unit) {
|
||||||
|
Dialog(onDismissRequest = onCancel) {
|
||||||
|
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
|
||||||
|
Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) {
|
||||||
|
Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text("Are you sure you want to delete ${benchmark.name}?")
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddBenchmarkDialog(onCancel: () -> Unit, onAdd: (name: String) -> Unit) {
|
||||||
|
Dialog(onDismissRequest = onCancel) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
|
||||||
|
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
|
||||||
|
Text("Create benchmark", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||||
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
CancelConfirmXRow(onCancel, {
|
||||||
|
onAdd(name.trim())
|
||||||
|
}, confirmText = "Create", canConfirm = name.trim().isNotBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EditBenchmarkDialog(benchmark: ProjectVM.Benchmark, onCancel: () -> Unit, onUpdate: (name: String) -> Unit) {
|
||||||
|
Dialog(onDismissRequest = onCancel) {
|
||||||
|
var name by remember { mutableStateOf(benchmark.name) }
|
||||||
|
|
||||||
|
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
|
||||||
|
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
|
||||||
|
Text("Modify benchmark", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||||
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
CancelConfirmXRow(onCancel, {
|
||||||
|
onUpdate(name.trim())
|
||||||
|
}, confirmText = "Update", canConfirm = name.trim().isNotBlank() && name.trim() != benchmark.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddVersionDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color, meta: String) -> Unit) {
|
fun AddVersionDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color, meta: String) -> Unit) {
|
||||||
Dialog(onDismissRequest = onCancel) {
|
Dialog(onDismissRequest = onCancel) {
|
||||||
@@ -210,7 +458,7 @@ fun AddVersionDialog(onCancel: () -> Unit, onAdd: (name: String, color: Color, m
|
|||||||
|
|
||||||
CancelConfirmXRow(onCancel, {
|
CancelConfirmXRow(onCancel, {
|
||||||
onAdd(name.trim(), color, meta.trim())
|
onAdd(name.trim(), color, meta.trim())
|
||||||
}, confirmText = "Create", canConfirm = name.trim().isNotBlank() && meta.trim().isNotBlank())
|
}, confirmText = "Create", canConfirm = name.trim().isNotBlank())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,13 +474,7 @@ fun QuickVersion(lbl: ProjectVM.Version, hollow: Boolean = false) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.width(15.dp))
|
Spacer(Modifier.width(15.dp))
|
||||||
Column {
|
Column {
|
||||||
Row {
|
Text(lbl.name, overflow = TextOverflow.Ellipsis, maxLines = 1)
|
||||||
Text(lbl.name)
|
|
||||||
Text(" ${lbl.colorStr}", Modifier.align(Alignment.Bottom),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = LocalContentColor.current.copy(alpha = 0.75f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if(lbl.meta.isNotBlank())
|
if(lbl.meta.isNotBlank())
|
||||||
Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall,
|
Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall,
|
||||||
color = LocalContentColor.current.copy(0.75f))
|
color = LocalContentColor.current.copy(0.75f))
|
||||||
@@ -301,7 +543,7 @@ fun AddEntryDialog(
|
|||||||
|
|
||||||
DropdownMenuItem({
|
DropdownMenuItem({
|
||||||
Text(
|
Text(
|
||||||
"Create new label...",
|
"Create new version...",
|
||||||
color = LocalContentColor.current.copy(alpha = 0.75f),
|
color = LocalContentColor.current.copy(alpha = 0.75f),
|
||||||
fontStyle = FontStyle.Italic
|
fontStyle = FontStyle.Italic
|
||||||
)
|
)
|
||||||
@@ -378,38 +620,82 @@ fun AddEntryDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProjectPlotArea(vm: ProjectVM){
|
fun EditVersionDialog(current: ProjectVM.Version, onCancel: () -> Unit, onUpdate: (id: Uuid, name: String?, color: Color?, meta: String?) -> Unit) {
|
||||||
val entries by vm.entries
|
Dialog(onDismissRequest = onCancel) {
|
||||||
|
var name by remember { mutableStateOf(current.name) }
|
||||||
|
var color by remember { mutableStateOf(current.uiColor) }
|
||||||
|
var meta by remember { mutableStateOf(current.meta) }
|
||||||
|
|
||||||
|
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
|
||||||
|
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
|
||||||
|
Text("Create version", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||||
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
KolorPicker(color, { color = it }, alphaTrackVisible = false, modifier = Modifier.aspectRatio(1f))
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
OutlinedTextField(meta, { meta = it }, Modifier.fillMaxWidth(), label = { Text("Additional information") })
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
CancelConfirmXRow(onCancel, {
|
||||||
|
val useName = name.trim() nonEq current.name
|
||||||
|
val useColor = color nonEq current.uiColor
|
||||||
|
val useMeta = meta.trim() nonEq current.meta
|
||||||
|
onUpdate(current.id, useName, useColor, useMeta)
|
||||||
|
}, confirmText = "Update", canConfirm = name.trim().isNotBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
|
||||||
|
val entries = benchmark.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 allInputs by vm.inputs
|
||||||
val allHardware by vm.hardware
|
val allHardware by vm.hardware
|
||||||
|
|
||||||
|
val visibleInputs by remember(entries, allInputs) {
|
||||||
|
mutableStateOf(allInputs intersect entries.map { it.input }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
var filteredEntries by remember { mutableStateOf(entries) }
|
var filteredEntries by remember { mutableStateOf(entries) }
|
||||||
var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) }
|
var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) }
|
||||||
var timeMax by remember { mutableStateOf(Instant.DISTANT_FUTURE) }
|
var timeMax by remember { mutableStateOf(Instant.DISTANT_FUTURE) }
|
||||||
var timeFilter by remember { mutableStateOf(0f..1f) }
|
var timeFilter by remember { mutableStateOf(0f..1f) }
|
||||||
var timeFilterString by remember { mutableStateOf("") }
|
var timeFilterString by remember { mutableStateOf("") }
|
||||||
val versionFilter = remember(versions) { mutableStateSetFrom(versions.values) }
|
val versionFilter = remember(versions) { mutableStateSetFrom(versions.values) }
|
||||||
val labelVersionFilter by versionFilter.revision
|
val inputFilter = remember(allInputs) { mutableStateSetFrom(allInputs) }
|
||||||
val enabledInputs = remember { mutableStateSetOf<String>() }
|
|
||||||
val enabledHardware = remember { mutableStateSetOf<String>() }
|
|
||||||
var filterInput by remember { mutableStateOf<String?>(null) }
|
|
||||||
var filterHardware by remember { mutableStateOf<String?>(null) }
|
var filterHardware by remember { mutableStateOf<String?>(null) }
|
||||||
|
val versionFilterRev by versionFilter.revision
|
||||||
|
val inputFilterRev by inputFilter.revision
|
||||||
|
|
||||||
LaunchedEffect(entries, allInputs, allHardware, timeFilter, timeMin, timeMax, labelVersionFilter, filterInput, filterHardware) {
|
val enabledHardware = remember(allHardware) { mutableStateSetOf<String>() }
|
||||||
|
val enabledInputs = remember(allInputs) { mutableStateSetOf<String>() }
|
||||||
|
|
||||||
|
LaunchedEffect(
|
||||||
|
entries,
|
||||||
|
allInputs,
|
||||||
|
allHardware,
|
||||||
|
timeFilter,
|
||||||
|
timeMin,
|
||||||
|
timeMax,
|
||||||
|
versionFilterRev,
|
||||||
|
inputFilterRev,
|
||||||
|
filterHardware
|
||||||
|
) {
|
||||||
val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
|
val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
|
||||||
enabledInputs.clear()
|
|
||||||
enabledHardware.clear()
|
enabledHardware.clear()
|
||||||
|
enabledInputs.clear()
|
||||||
|
|
||||||
filteredEntries = entries.filter { entry ->
|
filteredEntries = entries.filter { entry ->
|
||||||
if(entry.label !in versionFilter) return@filter false
|
if (entry.label !in versionFilter) return@filter false
|
||||||
if(!(entry.timeStamp inRange timeRange)) return@filter false
|
if (!(entry.timeStamp inRange timeRange)) return@filter false
|
||||||
if(filterInput != null && entry.input != filterInput) return@filter false
|
if (entry.input !in inputFilter) return@filter false
|
||||||
if(filterHardware != null && entry.hardware != filterHardware) return@filter false
|
if (filterHardware != null && entry.hardware != filterHardware) return@filter false
|
||||||
|
|
||||||
enabledInputs.add(entry.input)
|
|
||||||
enabledHardware.add(entry.hardware)
|
enabledHardware.add(entry.hardware)
|
||||||
|
enabledInputs.add(entry.input)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,7 +713,7 @@ fun ProjectPlotArea(vm: ProjectVM){
|
|||||||
var maxY = 0f
|
var maxY = 0f
|
||||||
|
|
||||||
renderableEntries = filteredEntries.map { entry ->
|
renderableEntries = filteredEntries.map { entry ->
|
||||||
val use = if(displayWarmups) entry.warmups else entry.measurements
|
val use = if (displayWarmups) entry.warmups else entry.measurements
|
||||||
maxX = maxOf(maxX, use.size.toFloat())
|
maxX = maxOf(maxX, use.size.toFloat())
|
||||||
use.mapIndexed { idx, it ->
|
use.mapIndexed { idx, it ->
|
||||||
val converted = entry.nativeUnit.convertTo(currentUnit, it)
|
val converted = entry.nativeUnit.convertTo(currentUnit, it)
|
||||||
@@ -444,6 +730,7 @@ fun ProjectPlotArea(vm: ProjectVM){
|
|||||||
|
|
||||||
|
|
||||||
var deletingVersion by remember { mutableStateOf<ProjectVM.Version?>(null) }
|
var deletingVersion by remember { mutableStateOf<ProjectVM.Version?>(null) }
|
||||||
|
var editingVersion by remember { mutableStateOf<ProjectVM.Version?>(null) }
|
||||||
var baseline 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 baselineComparison by remember { mutableStateOf<List<Tuple4<String, Float, Color, Float>>?>(null) }
|
||||||
var baselineMax by remember { mutableStateOf(2.0f) }
|
var baselineMax by remember { mutableStateOf(2.0f) }
|
||||||
@@ -460,21 +747,41 @@ fun ProjectPlotArea(vm: ProjectVM){
|
|||||||
LaunchedEffect(filteredEntries, baseline, currentUnit) {
|
LaunchedEffect(filteredEntries, baseline, currentUnit) {
|
||||||
var max = 0.0f
|
var max = 0.0f
|
||||||
baselineComparison = baseline?.let { bl ->
|
baselineComparison = baseline?.let { bl ->
|
||||||
val perVersion = mutableMapOf<ProjectVM.Version, Pair<Float, Float>>()
|
class MeanAccumulator(var sum: Float = 0.0f, var count: Float = 0.0f) {
|
||||||
filteredEntries.forEach { entry ->
|
fun add(f: Float) {
|
||||||
val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum()
|
sum += f; count += 1.0f
|
||||||
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 }
|
fun add(f: Float, countsAs: Int) {
|
||||||
val baselineData = means[bl] ?: return@let null
|
sum += f; count += countsAs.toFloat()
|
||||||
means.mapNotNull { (ver, data) ->
|
|
||||||
val speedup = baselineData / data
|
|
||||||
max = maxOf(max, speedup)
|
|
||||||
ver.name to speedup app ver.uiColor app data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun mean() = if (count == 0.0f) 0.0f else (sum / count)
|
||||||
|
}
|
||||||
|
|
||||||
|
val perVersion = mutableMapOf<ProjectVM.Version, MutableMap<String, MeanAccumulator>>()
|
||||||
|
filteredEntries.forEach { entry ->
|
||||||
|
val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum()
|
||||||
|
perVersion.getOrPut(entry.label) { mutableMapOf() }
|
||||||
|
.getOrPut(entry.input) { MeanAccumulator() }
|
||||||
|
.add(corrected, entry.measurements.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
val subMeans = perVersion.mapValues { (_, v) -> v.mapValues { (_, acc) -> acc.mean() } }
|
||||||
|
val baseline = subMeans[bl] ?: return@let null
|
||||||
|
|
||||||
|
val versionSpeedups = subMeans.mapValues { (_, v) ->
|
||||||
|
v.mapNotNull { (input, mean) ->
|
||||||
|
val base = baseline[input] ?: return@mapNotNull null
|
||||||
|
(base / mean) to mean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
versionSpeedups.mapNotNull { (version, inputMeans) ->
|
||||||
|
val speedup = inputMeans.map { it.first }.geomean()
|
||||||
|
max = maxOf(max, speedup)
|
||||||
|
version.name to speedup app version.uiColor app inputMeans.map { it.second }.geomean()
|
||||||
|
}.sortedBy { it.t1 }
|
||||||
}
|
}
|
||||||
baselineMax = max
|
baselineMax = max
|
||||||
}
|
}
|
||||||
@@ -488,18 +795,33 @@ fun ProjectPlotArea(vm: ProjectVM){
|
|||||||
verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor)
|
verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor)
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(Modifier.fillMaxSize().padding(20.dp)) {
|
val uiMode = GlobalUiMode.current
|
||||||
Row(Modifier.weight(0.66f).fillMaxHeight()) {
|
var showOptions by remember(uiMode) { mutableStateOf(uiMode == UiMode.WIDE) }
|
||||||
if(entries.isEmpty()) {
|
|
||||||
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
|
RowOrPanel(useRow = GlobalUiMode.current == UiMode.WIDE, Modifier.fillMaxSize()) {
|
||||||
|
mainContent { isNarrow, toggleSide ->
|
||||||
|
Box {
|
||||||
|
Row(Modifier.padding(end = if (isNarrow) 25.dp else 0.dp)) {
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f).padding(35.dp)) {
|
||||||
Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
|
Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
Column(Modifier.padding(35.dp)) {
|
||||||
Column {
|
|
||||||
Text("Measurements", style = MaterialTheme.typography.headlineSmall)
|
|
||||||
Row {
|
Row {
|
||||||
Row(Modifier.weight(0.66f), verticalAlignment = Alignment.CenterVertically) {
|
Text("Measurements", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
if (showOptions) "(hide options)" else "(show options)",
|
||||||
|
Modifier.clickable { showOptions = !showOptions }.align(Alignment.Bottom),
|
||||||
|
color = linkColor,
|
||||||
|
textDecoration = TextDecoration.Underline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showOptions) {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text("View as: ", fontWeight = FontWeight.Bold)
|
Text("View as: ", fontWeight = FontWeight.Bold)
|
||||||
Spacer(Modifier.width(5.dp))
|
Spacer(Modifier.width(5.dp))
|
||||||
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
|
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
|
||||||
@@ -507,45 +829,56 @@ fun ProjectPlotArea(vm: ProjectVM){
|
|||||||
SegmentedButton(
|
SegmentedButton(
|
||||||
currentUnit == it,
|
currentUnit == it,
|
||||||
{ currentUnit = it },
|
{ currentUnit = it },
|
||||||
shape = SegmentedButtonDefaults.itemShape(idx, TimeUnit.entries.size)
|
shape = SegmentedButtonDefaults.itemShape(
|
||||||
|
idx,
|
||||||
|
TimeUnit.entries.size
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Text(it.disp)
|
Text(it.disp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
Spacer(Modifier.width(50.dp))
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
||||||
Row(Modifier.weight(0.33f), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text("Display: ", fontWeight = FontWeight.Bold)
|
Text("Display: ", fontWeight = FontWeight.Bold)
|
||||||
Spacer(Modifier.width(5.dp))
|
Spacer(Modifier.width(5.dp))
|
||||||
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
|
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
|
||||||
SegmentedButton(!displayWarmups, { displayWarmups = false },
|
SegmentedButton(
|
||||||
|
!displayWarmups, { displayWarmups = false },
|
||||||
shape = SegmentedButtonDefaults.itemShape(0, 2)
|
shape = SegmentedButtonDefaults.itemShape(0, 2)
|
||||||
) {
|
) {
|
||||||
Text("(Steady-state) measurements")
|
Text("(Steady-state) measurements")
|
||||||
}
|
}
|
||||||
|
|
||||||
SegmentedButton(displayWarmups, { displayWarmups = true },
|
SegmentedButton(
|
||||||
|
displayWarmups, { displayWarmups = true },
|
||||||
shape = SegmentedButtonDefaults.itemShape(1, 2)
|
shape = SegmentedButtonDefaults.itemShape(1, 2)
|
||||||
) {
|
) {
|
||||||
Text("Warmup measurements")
|
Text("Warmup measurements")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Spacer(Modifier.height(5.dp))
|
Spacer(Modifier.height(5.dp))
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text("Filter by timestamp: ", fontWeight = FontWeight.Bold)
|
Text("Filter by timestamp: ", fontWeight = FontWeight.Bold)
|
||||||
Spacer(Modifier.width(5.dp))
|
Spacer(Modifier.width(5.dp))
|
||||||
RangeSlider(timeFilter, { timeFilter = it }, Modifier.fillMaxWidth(), valueRange = 0f..1f)
|
RangeSlider(
|
||||||
|
timeFilter,
|
||||||
|
{ timeFilter = it },
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
valueRange = 0f..1f
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Text(timeFilterString, Modifier.align(Alignment.End))
|
Text(timeFilterString, Modifier.align(Alignment.End))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(15.dp))
|
Spacer(Modifier.height(15.dp))
|
||||||
|
|
||||||
|
if (!isNarrow || baselineComparison == null) {
|
||||||
XYGraph(
|
XYGraph(
|
||||||
rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()),
|
rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()),
|
||||||
rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()),
|
rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()),
|
||||||
@@ -567,35 +900,59 @@ fun ProjectPlotArea(vm: ProjectVM){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else baselineComparison?.let { blc ->
|
||||||
|
baselineGraph(baseline, baselineMax, style, gridStyle, blc, currentUnit)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.width(20.dp))
|
if (isNarrow) {
|
||||||
|
ExpandableSidePanel(
|
||||||
|
Modifier.width(70.dp).fillMaxHeight().align(Alignment.CenterEnd),
|
||||||
|
toggleSide = toggleSide, elevation = 10.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(Modifier.weight(0.33f).fillMaxHeight()) {
|
sideContent(false) { isNarrow, toggleSide ->
|
||||||
Row {
|
val wrapper: @Composable (@Composable () -> Unit) -> Unit = if (isNarrow) { it ->
|
||||||
|
ExpandedSidePanel(Modifier.fillMaxWidth(0.8f), toggleSide = toggleSide) {
|
||||||
|
Box(Modifier.padding(10.dp)) { it() }
|
||||||
|
}
|
||||||
|
} else { it -> it() }
|
||||||
|
|
||||||
|
wrapper {
|
||||||
|
Column(Modifier.fillMaxHeight()) {
|
||||||
|
Row(Modifier.weight(0.33f)) {
|
||||||
Column(Modifier.weight(0.5f)) {
|
Column(Modifier.weight(0.5f)) {
|
||||||
Text("Versions", style = MaterialTheme.typography.headlineSmall)
|
Text("Versions", style = MaterialTheme.typography.headlineSmall)
|
||||||
Text("Click a version to toggle its visibility.", fontStyle = FontStyle.Italic,
|
Text(
|
||||||
color = LocalContentColor.current.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall)
|
"Click a version to toggle its visibility.",
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
color = LocalContentColor.current.copy(alpha = 0.5f),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
if(versions.isEmpty()) {
|
if (versions.isEmpty()) {
|
||||||
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
|
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
|
||||||
Text("No versions yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
|
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
|
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)
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Row {
|
Row {
|
||||||
@@ -612,16 +969,39 @@ fun ProjectPlotArea(vm: ProjectVM){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton({ baseline = if(baseline == lbl) null else lbl },
|
if (editable) {
|
||||||
Modifier.align(Alignment.CenterVertically), enabled = lbl in versionFilter) {
|
IconButton(
|
||||||
if(baseline == lbl) {
|
{ editingVersion = lbl },
|
||||||
Surface(Modifier.padding(5.dp), color = MaterialTheme.colorScheme.inverseSurface, shape = MaterialTheme.shapes.large) {
|
Modifier.align(Alignment.CenterVertically)
|
||||||
Icon(Lucide.RulerDimensionLine, "Use as baseline", Modifier.padding(5.dp))
|
) {
|
||||||
|
Icon(Lucide.Pencil, "Edit version")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else Icon(Lucide.RulerDimensionLine, "Use as baseline")
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconButton({ deletingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) {
|
} else Icon(Lucide.RulerDimensionLine, "Use as baseline")
|
||||||
|
}
|
||||||
|
if (editable) {
|
||||||
|
IconButton(
|
||||||
|
{ deletingVersion = lbl },
|
||||||
|
Modifier.align(Alignment.CenterVertically)
|
||||||
|
) {
|
||||||
Icon(Lucide.Trash, "Delete version")
|
Icon(Lucide.Trash, "Delete version")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,24 +1010,63 @@ fun ProjectPlotArea(vm: ProjectVM){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.width(15.dp))
|
Spacer(Modifier.width(15.dp))
|
||||||
LazyColumn(Modifier.weight(0.5f)) {
|
LazyColumn(Modifier.weight(0.5f)) {
|
||||||
item { Text("Inputs", style = MaterialTheme.typography.headlineSmall) }
|
item { Text("Inputs", style = MaterialTheme.typography.headlineSmall) }
|
||||||
item {
|
item {
|
||||||
Text("Click an input to set or clear the filter.", fontStyle = FontStyle.Italic,
|
Text(
|
||||||
color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall)
|
"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)) }
|
item { Spacer(Modifier.height(10.dp)) }
|
||||||
|
|
||||||
items((allInputs intersect enabledInputs.toSet()).toList()) {
|
items((visibleInputs intersect inputFilter.toSet()).sorted()) {
|
||||||
Row(Modifier.fillMaxWidth().clickable { filterInput = if(filterInput == null) it else null }) {
|
Row(Modifier.fillMaxWidth().clickable {
|
||||||
Text(it, Modifier.padding(start = 5.dp))
|
if (inputFilter.size == allInputs.size) {
|
||||||
|
inputFilter.clear()
|
||||||
|
inputFilter.add(it)
|
||||||
|
} else if (inputFilter.size == 1) {
|
||||||
|
inputFilter.addAll(allInputs.toSet()); inputFilter.remove(it)
|
||||||
|
} else {
|
||||||
|
inputFilter.toggle(it)
|
||||||
|
if (inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
Modifier.padding(start = 5.dp),
|
||||||
|
color = LocalContentColor.current.copy(alpha = if (it in enabledInputs) 1.0f else 0.9f)
|
||||||
|
)
|
||||||
|
if (it !in enabledInputs) Text(
|
||||||
|
" (no data)",
|
||||||
|
Modifier.align(Alignment.Bottom),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = LocalContentColor.current.copy(0.9f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items((allInputs - enabledInputs.toSet()).toList()) {
|
items((visibleInputs - inputFilter.toSet()).sorted()) {
|
||||||
Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f))
|
Row(Modifier.fillMaxWidth().clickable {
|
||||||
|
if (inputFilter.size == allInputs.size) {
|
||||||
|
inputFilter.clear()
|
||||||
|
inputFilter.add(it)
|
||||||
|
} else {
|
||||||
|
inputFilter.toggle(it)
|
||||||
|
if (inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
Modifier.padding(start = 5.dp),
|
||||||
|
color = LocalContentColor.current.copy(0.66f)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item { Spacer(Modifier.height(10.dp)) }
|
item { Spacer(Modifier.height(10.dp)) }
|
||||||
@@ -656,30 +1075,70 @@ fun ProjectPlotArea(vm: ProjectVM){
|
|||||||
|
|
||||||
item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) }
|
item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) }
|
||||||
item {
|
item {
|
||||||
Text("Click an item to set or clear the filter.", fontStyle = FontStyle.Italic,
|
Text(
|
||||||
color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall)
|
"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)) }
|
item { Spacer(Modifier.height(10.dp)) }
|
||||||
|
|
||||||
items((allHardware intersect enabledHardware.toSet()).toList()) {
|
items((allHardware intersect enabledHardware.toSet()).toList()) {
|
||||||
Row(Modifier.fillMaxWidth().clickable { filterHardware = if(filterHardware == null) it else null }) {
|
Row(
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.clickable { filterHardware = if (filterHardware == null) it else null }) {
|
||||||
Text(it, Modifier.padding(start = 5.dp))
|
Text(it, Modifier.padding(start = 5.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items((allHardware - enabledHardware.toSet()).toList()) {
|
items((allHardware - enabledHardware.toSet()).toList()) {
|
||||||
Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f))
|
Text(
|
||||||
|
it,
|
||||||
|
Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp),
|
||||||
|
color = LocalContentColor.current.copy(0.66f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.weight(1f)) {
|
if (!isNarrow) {
|
||||||
baselineComparison?.let { blc ->
|
baselineComparison?.let { blc ->
|
||||||
|
baselineGraph(baseline, baselineMax, style, gridStyle, blc, currentUnit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingVersion?.let { rmV ->
|
||||||
|
ConfirmDeleteVersionDialog(rmV, { deletingVersion = null }) {
|
||||||
|
vm.deleteVersion(rmV.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editingVersion?.let { edV ->
|
||||||
|
EditVersionDialog(edV, { editingVersion = null }) { id, name, color, meta ->
|
||||||
|
vm.updateLabel(id, name, color, meta)
|
||||||
|
editingVersion = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ColumnScope.baselineGraph(baseline: ProjectVM.Version?, baselineMax: Float, style: AxisStyle, gridStyle: GridStyle,
|
||||||
|
blc: List<Tuple4<String, Float, Color, Float>>, currentUnit: TimeUnit) {
|
||||||
|
Column(Modifier.weight(0.66f)) {
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
HorizontalDivider(Modifier.height(1.dp))
|
HorizontalDivider(Modifier.height(1.dp))
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text("Speedup compared to ${baseline?.name ?: "<unknown version>"}", style = MaterialTheme.typography.headlineSmall)
|
Text(
|
||||||
|
"Speedup compared to ${baseline?.name ?: "<unknown version>"}",
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
val (xs, ys, colors) = blc.unzip()
|
val (xs, ys, colors) = blc.unzip()
|
||||||
@@ -703,25 +1162,24 @@ fun ProjectPlotArea(vm: ProjectVM){
|
|||||||
VerticalBarPlot(
|
VerticalBarPlot(
|
||||||
xData = xs,
|
xData = xs,
|
||||||
yData = ys,
|
yData = ys,
|
||||||
bar = { x, _, _ -> DefaultBar(brush = SolidColor(colors[x]), modifier = Modifier.fillMaxWidth()) }
|
bar = { x, _, _ ->
|
||||||
|
DefaultBar(
|
||||||
|
brush = SolidColor(colors[x]),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
blc.forEach { (x, y, _, time) ->
|
blc.forEach { (x, y, _, time) ->
|
||||||
XYAnnotation(Point(x, y), AnchorPoint.TopCenter) {
|
XYAnnotation(Point(x, y), AnchorPoint.TopCenter) {
|
||||||
Text("${format(y)}x\n${format(time)} ${currentUnit.disp}", Modifier.padding(top = 5.dp))
|
Text(
|
||||||
|
"${format(y)}x\n${format(time)} ${currentUnit.disp}",
|
||||||
|
Modifier.padding(top = 5.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deletingVersion?.let { rmV ->
|
|
||||||
ConfirmDeleteVersionDialog(rmV, { deletingVersion = null }) {
|
|
||||||
vm.deleteVersion(rmV.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -731,7 +1189,7 @@ fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit,
|
|||||||
Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) {
|
Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) {
|
||||||
Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text("Are you sure you want to delete $version?")
|
Text("Are you sure you want to delete ${version.name}?")
|
||||||
Text("This will also remove all data associated with this version.")
|
Text("This will also remove all data associated with this version.")
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
|
CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
|
||||||
|
|||||||
@@ -1,10 +1,75 @@
|
|||||||
package com.jaytux.phoebench.clients
|
package com.jaytux.phoebench.clients
|
||||||
|
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.awt.SwingPanel
|
||||||
|
import androidx.compose.ui.unit.DpOffset
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.*
|
||||||
|
import com.jaytux.phoebench.clients.theme.GlobalUiMode
|
||||||
|
import com.jaytux.phoebench.clients.theme.UiMode
|
||||||
|
import java.awt.GraphicsConfiguration
|
||||||
|
import java.awt.GraphicsEnvironment
|
||||||
|
import java.awt.Toolkit
|
||||||
|
import java.awt.event.ComponentEvent
|
||||||
|
import java.awt.event.ComponentListener
|
||||||
|
import javax.swing.JPanel
|
||||||
|
|
||||||
|
fun windowData(config: GraphicsConfiguration): Pair<DpSize, DpOffset> {
|
||||||
|
val bounds = config.bounds
|
||||||
|
val insets = Toolkit.getDefaultToolkit().getScreenInsets(config)
|
||||||
|
|
||||||
|
val availableWidthPx = bounds.width - insets.left - insets.right
|
||||||
|
val availableHeightPx = bounds.height - insets.top - insets.bottom
|
||||||
|
|
||||||
|
val density = config.defaultTransform.scaleX.toFloat()
|
||||||
|
val size = DpSize((availableWidthPx / density).dp, (availableHeightPx / density).dp)
|
||||||
|
val offset = DpOffset((insets.left / density).dp, (insets.top / density).dp)
|
||||||
|
return size to offset
|
||||||
|
}
|
||||||
|
|
||||||
fun main() = application {
|
fun main() = application {
|
||||||
Window(onCloseRequest = ::exitApplication, title = "PhoeBench") {
|
val (winSize, winOffset) = windowData(GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration)
|
||||||
|
val state = rememberWindowState(placement = WindowPlacement.Maximized, size = winSize, position = WindowPosition.Aligned(Alignment.Center))
|
||||||
|
|
||||||
|
var mode by remember { mutableStateOf(UiMode.NARROW) }
|
||||||
|
|
||||||
|
Window(onCloseRequest = ::exitApplication, state, title = "PhoeBench") {
|
||||||
|
var width by remember { mutableStateOf(window.width.dp) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
window.addComponentListener(object : ComponentListener {
|
||||||
|
override fun componentResized(e: ComponentEvent?) {
|
||||||
|
width = e?.component?.width?.dp ?: width
|
||||||
|
}
|
||||||
|
override fun componentMoved(e: ComponentEvent?) {}
|
||||||
|
override fun componentShown(e: ComponentEvent?) {}
|
||||||
|
override fun componentHidden(e: ComponentEvent?) {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mode = UiMode.fromDp(width)
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
SwingPanel(modifier = Modifier.size(0.dp), factory = {
|
||||||
|
JPanel().apply {
|
||||||
|
addPropertyChangeListener("graphicsConfiguration") {
|
||||||
|
val (newSize, newOffset) = windowData(this@Window.window.graphicsConfiguration)
|
||||||
|
state.size = newSize
|
||||||
|
state.position = WindowPosition.Aligned(Alignment.Center)
|
||||||
|
mode = UiMode.fromDp(state.size.width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
CompositionLocalProvider(GlobalUiMode provides mode) {
|
||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+17
-2
@@ -2,6 +2,7 @@ package com.jaytux.phoebench.clients
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.draganddrop.*
|
||||||
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 com.jaytux.phoebench.clients.ui.DefaultServerSelect
|
||||||
@@ -10,12 +11,13 @@ import io.ktor.client.engine.okhttp.*
|
|||||||
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.awt.datatransfer.DataFlavor
|
||||||
import java.awt.datatransfer.StringSelection
|
import java.awt.datatransfer.StringSelection
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
object JVMStore : IStore {
|
object JVMStore : IStore {
|
||||||
private val json = Json
|
private val json = Json
|
||||||
const val SERVICE = "com.jaytux.phoebench"
|
const val SERVICE = PersistenceConstants.SERVICE
|
||||||
|
|
||||||
private class StoredProperty<T>(
|
private class StoredProperty<T>(
|
||||||
val key: String,
|
val key: String,
|
||||||
@@ -59,7 +61,20 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
|
|||||||
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)
|
actual fun format(f: Float): String = String.format("%.03f", f).let { it.substring(0, minOf(it.length, 5)) }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate)
|
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
actual fun <T> drag(data: T, serializer: KSerializer<T>): DragAndDropTransferData =
|
||||||
|
DragAndDropTransferData(
|
||||||
|
DragAndDropTransferable(StringSelection(Json.encodeToString(serializer, data))),
|
||||||
|
supportedActions = listOf(DragAndDropTransferAction.Move)
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
actual fun <T> drop(data: DragAndDropEvent, serializer: KSerializer<T>): T? =
|
||||||
|
if(data.awtTransferable.isDataFlavorSupported(DataFlavor.stringFlavor))
|
||||||
|
Json.decodeFromString(serializer, data.awtTransferable.getTransferData(DataFlavor.stringFlavor) as String)
|
||||||
|
else null
|
||||||
@@ -1,12 +1,30 @@
|
|||||||
package com.jaytux.phoebench.clients
|
package com.jaytux.phoebench.clients
|
||||||
|
|
||||||
|
import androidx.compose.runtime.CompositionLocal
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.ComposeViewport
|
import androidx.compose.ui.window.ComposeViewport
|
||||||
|
import com.jaytux.phoebench.clients.theme.GlobalUiMode
|
||||||
|
import com.jaytux.phoebench.clients.theme.UiMode
|
||||||
import kotlinx.browser.document
|
import kotlinx.browser.document
|
||||||
|
import kotlinx.browser.window
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun main() {
|
suspend fun main() {
|
||||||
|
config = loadConfig()
|
||||||
ComposeViewport(document.body!!) {
|
ComposeViewport(document.body!!) {
|
||||||
|
val width = LocalWindowInfo.current.containerSize
|
||||||
|
var mode by remember(width) { mutableStateOf(UiMode.fromDp(width.width.dp)) }
|
||||||
|
|
||||||
|
CompositionLocalProvider(GlobalUiMode provides mode) {
|
||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+36
-2
@@ -1,14 +1,21 @@
|
|||||||
package com.jaytux.phoebench.clients
|
package com.jaytux.phoebench.clients
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropTransferData
|
||||||
|
import androidx.compose.ui.draganddrop.domDataTransferOrNull
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import com.jaytux.phoebench.clients.ui.constantServerSelect
|
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.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.serializer
|
import kotlinx.serialization.serializer
|
||||||
|
import org.w3c.dom.DataTransfer
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
object WasmJsStore : IStore {
|
object WasmJsStore : IStore {
|
||||||
@@ -54,9 +61,36 @@ actual suspend fun String.toClipEntry(): ClipEntry =
|
|||||||
@JsFun("(num) => num.toFixed(3)")
|
@JsFun("(num) => num.toFixed(3)")
|
||||||
private external fun jsFormat(num: Double): String
|
private external fun jsFormat(num: Double): String
|
||||||
|
|
||||||
actual fun format(f: Float): String = jsFormat(f.toDouble())
|
actual fun format(f: Float): String = jsFormat(f.toDouble()).let { it.substring(0, minOf(it.length, 5)) }
|
||||||
|
|
||||||
|
@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
|
@Composable
|
||||||
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) {
|
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) {
|
||||||
constantServerSelect(WasmConfig.SERVER_URL)(server, onUpdate)
|
constantServerSelect(config.server)(server, onUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalWasmJsInterop::class)
|
||||||
|
@JsFun("() => new DataTransfer()")
|
||||||
|
private external fun createDataTransfer(): DataTransfer
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
actual fun <T> drag(data: T, serializer: KSerializer<T>): DragAndDropTransferData = DragAndDropTransferData(
|
||||||
|
createDataTransfer().also { it.setData("application/json", Json.encodeToString(serializer, data)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
actual fun <T> drop(data: DragAndDropEvent, serializer: KSerializer<T>): T? =
|
||||||
|
data.transferData?.domDataTransferOrNull?.getData("application/json")?.let {
|
||||||
|
Json.decodeFromString(serializer, it)
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>PhoeBench</title>
|
<title>PhoeBench</title>
|
||||||
<link type="text/css" rel="stylesheet" href="styles.css">
|
<link type="text/css" rel="stylesheet" href="styles.css" />
|
||||||
|
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-Regular.ttf" />
|
||||||
|
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-Bold.ttf" />
|
||||||
|
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-Italic.ttf" />
|
||||||
|
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-BoldItalic.ttf" />
|
||||||
<script type="application/javascript" src="composeApp.js"></script>
|
<script type="application/javascript" src="composeApp.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ val partialsDirectory = layout.buildDirectory.dir("generated/sources/partials")
|
|||||||
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
|
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
|
||||||
val requestsDirectory = layout.projectDirectory.dir("src/commonMain/kotlin/com/jaytux/phoebench/common/")
|
val requestsDirectory = layout.projectDirectory.dir("src/commonMain/kotlin/com/jaytux/phoebench/common/")
|
||||||
|
|
||||||
|
version = rootProject.version as PhoebenchVersion
|
||||||
|
|
||||||
val generatePartials = tasks.register<Exec>("generatePartials") {
|
val generatePartials = tasks.register<Exec>("generatePartials") {
|
||||||
group = "generation"
|
group = "generation"
|
||||||
description = "Generate Partial classes (requests with all-nullable fields)"
|
description = "Generate Partial classes (requests with all-nullable fields)"
|
||||||
val scriptFile = project.file("partialize.main.kts")
|
val scriptFile = project.file("partialize.main.kts")
|
||||||
val targets = fileTree(requestsDirectory) {
|
val targets = fileTree(requestsDirectory) {
|
||||||
include("**/*.kt")
|
include("Requests.kt")
|
||||||
}
|
}
|
||||||
val lst = targets.map { it.absolutePath }
|
val lst = targets.map { it.absolutePath }
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ val generateVersion = tasks.register<Task>("protocolVersion") {
|
|||||||
package com.jaytux.phoebench.common
|
package com.jaytux.phoebench.common
|
||||||
|
|
||||||
object ProtocolVersion {
|
object ProtocolVersion {
|
||||||
const val VERSION="${rootProject.version}"
|
const val VERSION="${version}"
|
||||||
}
|
}
|
||||||
""".trimIndent())
|
""".trimIndent())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.jaytux.phoebench.common
|
||||||
|
|
||||||
|
import io.ktor.client.plugins.websocket.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.util.reflect.*
|
||||||
|
import io.ktor.utils.io.*
|
||||||
|
import io.ktor.websocket.*
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
sealed class CSERoute<TParams, TEvent: Any>(val path: String, val elevation: Elevation, private val _eventType: TypeInfo) {
|
||||||
|
open val pattern = path
|
||||||
|
|
||||||
|
protected open fun buildUrl(params: TParams): String = path
|
||||||
|
|
||||||
|
abstract fun extractParams(reqParams: Parameters): TParams?
|
||||||
|
|
||||||
|
suspend fun call(client: IClient, params: TParams, body: suspend (sender: suspend (TEvent) -> Unit) -> Unit): Either<ErrorResponse, Unit> {
|
||||||
|
val server = if(client.serverUrl.startsWith("http")) client.serverUrl.replace("http", "ws") else "ws://${client.serverUrl}"
|
||||||
|
var error: ErrorResponse? = null
|
||||||
|
try {
|
||||||
|
client.client.webSocket("$server${buildUrl(params)}", {}) {
|
||||||
|
try {
|
||||||
|
body { sendSerialized(it, _eventType) }
|
||||||
|
}
|
||||||
|
catch(e: CancellationException) {
|
||||||
|
closeReason.await()?.let {
|
||||||
|
if(it.code != CloseReason.Codes.NORMAL.code) error = ErrorResponse(it.message)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Unit.value()
|
||||||
|
}
|
||||||
|
catch(e: CancellationException) {
|
||||||
|
return (error ?: ErrorResponse(e.message ?: "Unknown websocket error")).error()
|
||||||
|
}
|
||||||
|
catch(e: WebSocketException) {
|
||||||
|
return ErrorResponse("Could not set up websocket stream: ${e.message}").error()
|
||||||
|
}
|
||||||
|
catch(e: Exception) {
|
||||||
|
return ErrorResponse("WebSocket connection failed: ${e.message}").error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CSERoute1<T1, TEvent: Any>(path: String, elevation: Elevation, eventType: TypeInfo, val urlEncode: (T1) -> String, val urlDecode: (String?) -> T1?)
|
||||||
|
: CSERoute<T1, TEvent>(path, elevation, eventType)
|
||||||
|
{
|
||||||
|
override val pattern: String = "$path/{param}"
|
||||||
|
override fun buildUrl(params: T1): String = "$path/${urlEncode(params)}"
|
||||||
|
override fun extractParams(reqParams: Parameters): T1? = urlDecode(reqParams["param"])
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
inline fun <T1, reified TEvent: Any> single(path: String, elevation: Elevation,
|
||||||
|
noinline urlEncode: (T1) -> String = { it.toString() }, noinline urlDecode: (String?) -> T1?
|
||||||
|
) = CSERoute1<T1, TEvent>(path, elevation, typeInfo<TEvent>(), urlEncode, urlDecode)
|
||||||
|
|
||||||
|
inline fun <reified TEvent: Any> uuid(path: String, elevation: Elevation) = single<Uuid, TEvent>(path, elevation) {
|
||||||
|
it?.let { p -> Uuid.parseOrNull(p) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.jaytux.phoebench.common
|
||||||
|
|
||||||
|
enum class CloseReasons(val code: Short) {
|
||||||
|
NOT_AUTHORIZED(4001),
|
||||||
|
INVALID_REQUEST(4002),
|
||||||
|
CONFLICT(4003),
|
||||||
|
NOT_FOUND(4004)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.jaytux.phoebench.common
|
package com.jaytux.phoebench.common
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.collections.fold
|
||||||
|
import kotlin.time.Instant
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -42,6 +44,15 @@ sealed class ProjectEvent {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class Updated(val changes: HomeResponse.ProjectSummary) : ProjectEvent()
|
data class Updated(val changes: HomeResponse.ProjectSummary) : ProjectEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NewBenchmark(val benchmark: BenchmarkResponse) : ProjectEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BenchmarkChanged(val benchmark: BenchmarkSummary) : ProjectEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BenchmarkDeleted(val id: Uuid) : ProjectEvent()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class NewVersion(val label: VersionResponse) : ProjectEvent()
|
data class NewVersion(val label: VersionResponse) : ProjectEvent()
|
||||||
|
|
||||||
@@ -55,5 +66,82 @@ sealed class ProjectEvent {
|
|||||||
data class NewEntry(val entry: EntryResponse) : ProjectEvent()
|
data class NewEntry(val entry: EntryResponse) : ProjectEvent()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class EntryDeleted(val id: Uuid) : ProjectEvent()
|
data class EntryDeleted(val id: Uuid, val benchmarkId: Uuid) : ProjectEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Stream {
|
||||||
|
STDOUT, STDERR
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ANSI(val bitIdx: Int, val ansiCode: Int) {
|
||||||
|
BOLD(0, 1), UNDERLINE(1, 4),
|
||||||
|
|
||||||
|
FG_RED(2, 31), FG_GREEN(3, 32), FG_YELLOW(4, 33),
|
||||||
|
FG_BLUE(5, 34), FG_GRAY(6, 90),
|
||||||
|
|
||||||
|
BG_RED(7, 41), BG_GREEN(8, 42), BG_YELLOW(9, 43),
|
||||||
|
BG_BLUE(10, 44), BG_GRAY(11, 100);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val _mapping: Map<Int, ANSI>
|
||||||
|
val ansiMapping: Map<Int, ANSI>
|
||||||
|
init {
|
||||||
|
if(entries.map { it.bitIdx }.toSet().size != entries.size)
|
||||||
|
throw IllegalStateException("ANSI bit indices contain duplicates")
|
||||||
|
if(entries.map { it.ansiCode }.toSet().size != entries.size)
|
||||||
|
throw IllegalStateException("ANSI escape codes contain duplicates")
|
||||||
|
|
||||||
|
_mapping = entries.associateBy { ansi -> ansi.bitIdx }
|
||||||
|
ansiMapping = entries.associateBy { ansi -> ansi.ansiCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pack(vararg options: ANSI): UShort = options.fold(0u) { acc, ansi ->
|
||||||
|
acc or (1u shl ansi.bitIdx).toUShort()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unpack(packed: UShort): List<ANSI> {
|
||||||
|
val res = mutableListOf<ANSI>()
|
||||||
|
var remaining = packed
|
||||||
|
for(i in 0..15) {
|
||||||
|
if((remaining and 1u) != 0.toUShort()) {
|
||||||
|
_mapping[i]?.let { res += it }
|
||||||
|
}
|
||||||
|
remaining = (remaining.toUInt() shr 1).toUShort()
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class ClientMonitorEvent {
|
||||||
|
@Serializable
|
||||||
|
data class Message(val msg: String, val stream: Stream, val options: UShort) : ClientMonitorEvent() {
|
||||||
|
constructor(msg: String, stream: Stream, vararg options: ANSI) : this(msg, stream, ANSI.pack(*options))
|
||||||
|
constructor(msg: String, stream: Stream, options: List<ANSI>) : this(msg, stream, ANSI.pack(*options.toTypedArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApplicationFinished(val exitCode: Int) : ClientMonitorEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class ServerMonitorEvent {
|
||||||
|
sealed interface ITextEvent
|
||||||
|
@Serializable
|
||||||
|
object Cleared : ServerMonitorEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApplicationStart(val time: Instant) : ServerMonitorEvent(), ITextEvent
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Message(val timeStamp: Instant, val message: String, val options: UShort, val stream: Stream) : ServerMonitorEvent(), ITextEvent
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Backlog(val start: ApplicationStart?, val messages: List<Message>, val end: ApplicationEnd?) : ServerMonitorEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApplicationEnd(val time: Instant, val exitCode: Int) : ServerMonitorEvent(), ITextEvent
|
||||||
}
|
}
|
||||||
@@ -19,13 +19,16 @@ class EmptyRequest
|
|||||||
data class RefreshRequest(val refreshToken: Uuid)
|
data class RefreshRequest(val refreshToken: Uuid)
|
||||||
|
|
||||||
@Serializable @ToPartialize
|
@Serializable @ToPartialize
|
||||||
data class ProjectRequest(val name: String, var isPublic: Boolean)
|
data class ProjectRequest(val name: String, val isPublic: Boolean)
|
||||||
|
|
||||||
@Serializable @ToPartialize
|
@Serializable @ToPartialize
|
||||||
data class VersionRequest(val name: String, val color: String, val meta: String, val projectId: Uuid)
|
data class BenchmarkRequest(val name: String, val projectId: Uuid)
|
||||||
|
|
||||||
@Serializable @ToPartialize
|
@Serializable @ToPartialize
|
||||||
data class EntryRequest(val version: Uuid, val timestamp: Instant, val projectId: Uuid, val warmups: List<Float>,
|
data class VersionRequest(val name: String, val color: String, val meta: String, val benchmarkId: Uuid)
|
||||||
|
|
||||||
|
@Serializable @ToPartialize
|
||||||
|
data class EntryRequest(val version: Uuid, val timestamp: Instant, val benchmarkId: Uuid, val warmups: List<Float>,
|
||||||
val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
|
val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -36,3 +39,9 @@ data class UserUpdateRequest(val projectLimit: Int? = null, val isAdmin: Boolean
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class InviteRequest(val asAdmin: Boolean)
|
data class InviteRequest(val asAdmin: Boolean)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Migrate0To1Request(val conversions: List<MigrateProject>) {
|
||||||
|
@Serializable
|
||||||
|
data class MigrateProject(val newProjectName: String, val isPublic: Boolean, val oldProjectsAsBenchmarks: List<Uuid>)
|
||||||
|
}
|
||||||
@@ -32,20 +32,35 @@ data class UserListResponse(val users: List<UserData>) {
|
|||||||
data class NamedID(val name: String, val id: Uuid)
|
data class NamedID(val name: String, val id: Uuid)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class HomeResponse(val username: String, val isAdmin: Boolean, val projectLimit: Int, val ownProjects: List<ProjectSummary>, val publicProjects: List<ProjectSummary>) {
|
data class HomeResponse(val username: String, val isAdmin: Boolean, val projectLimit: Int, val ownProjects: List<ProjectSummary>, val publicProjects: List<ProjectSummary>, val neededMigrations: List<NeededMigration>) {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ProjectSummary(val id: Uuid, val name: String, val isPublic: Boolean, val owner: NamedID)
|
data class ProjectSummary(val id: Uuid, val name: String, val isPublic: Boolean, val owner: NamedID)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class NeededMigration {
|
||||||
|
@Serializable
|
||||||
|
data class Migrate0To1(val projects: List<OldProject>) : NeededMigration() {
|
||||||
|
@Serializable
|
||||||
|
data class OldProject(val id: Uuid, val name: String, val versions: List<VersionResponse>)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ProjectResponse(val id: Uuid, val name: String, val owner: NamedID, val isPublic: Boolean, val isEditable: Boolean,
|
data class ProjectResponse(val id: Uuid, val name: String, val owner: NamedID, val isPublic: Boolean, val isEditable: Boolean,
|
||||||
val usedLabels: List<VersionResponse>, val entries: List<EntryResponse>)
|
val benchmarks: List<BenchmarkResponse>, val versions: List<VersionResponse>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BenchmarkSummary(val id: Uuid, val name: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BenchmarkResponse(val id: Uuid, val name: String, val entries: List<EntryResponse>)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class VersionResponse(val id: Uuid, val name: String, val color: String, val meta: String)
|
data class VersionResponse(val id: Uuid, val name: String, val color: String, val meta: String)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class EntryResponse(val id: Uuid, val labelId: Uuid, val timestamp: Instant, val warmups: List<Float>,
|
data class EntryResponse(val id: Uuid, val benchmarkId: Uuid, val versionId: Uuid, val timestamp: Instant, val warmups: List<Float>,
|
||||||
val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
|
val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -22,12 +22,20 @@ object Routes {
|
|||||||
|
|
||||||
val handshake = ApiRoute.get<HandshakeResponse>("/", Elevation.UN_AUTH)
|
val handshake = ApiRoute.get<HandshakeResponse>("/", Elevation.UN_AUTH)
|
||||||
val home = ApiRoute.get<HomeResponse>("/home", Elevation.AUTH)
|
val home = ApiRoute.get<HomeResponse>("/home", Elevation.AUTH)
|
||||||
|
val migrate0To1 = ApiRoute.post<Migrate0To1Request, HomeResponse>("/migrate/0-1", Elevation.AUTH)
|
||||||
|
|
||||||
object Project {
|
object Project {
|
||||||
val new = ApiRoute.post<ProjectRequest, ProjectResponse>("/project", Elevation.AUTH)
|
val new = ApiRoute.post<ProjectRequest, ProjectResponse>("/project", Elevation.AUTH)
|
||||||
val get = ApiRoute.getUuid<ProjectResponse>("/project", Elevation.AUTH)
|
val get = ApiRoute.getUuid<ProjectResponse>("/project", Elevation.AUTH)
|
||||||
val update = ApiRoute.patchUuidNoRes<PartialProjectRequest>("/project", Elevation.AUTH)
|
val update = ApiRoute.patchUuidNoRes<PartialProjectRequest>("/project", Elevation.AUTH)
|
||||||
val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH)
|
val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH)
|
||||||
|
val rmMonitor = ApiRoute.deleteUuidNoRes("/project/monitor", Elevation.AUTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
object Benchmark {
|
||||||
|
val new = ApiRoute.post<BenchmarkRequest, BenchmarkResponse>("/benchmark", Elevation.AUTH)
|
||||||
|
val update = ApiRoute.patchUuidNoRes<PartialBenchmarkRequest>("/benchmark", Elevation.AUTH)
|
||||||
|
val delete = ApiRoute.deleteUuidNoRes("/benchmark", Elevation.AUTH)
|
||||||
}
|
}
|
||||||
|
|
||||||
object Version {
|
object Version {
|
||||||
@@ -45,5 +53,10 @@ object Routes {
|
|||||||
val home = SSERoute.noArgs<HomeEvent>("/rt/home", Elevation.AUTH)
|
val home = SSERoute.noArgs<HomeEvent>("/rt/home", Elevation.AUTH)
|
||||||
val admin = SSERoute.noArgs<AdminEvent>("/rt/admin", Elevation.ADMIN)
|
val admin = SSERoute.noArgs<AdminEvent>("/rt/admin", Elevation.ADMIN)
|
||||||
val projectSpecific = SSERoute.uuid<ProjectEvent>("/rt/project", Elevation.AUTH)
|
val projectSpecific = SSERoute.uuid<ProjectEvent>("/rt/project", Elevation.AUTH)
|
||||||
|
val monitor = SSERoute.uuid<ServerMonitorEvent>("/rt/monitor", Elevation.AUTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
object CSE {
|
||||||
|
val monitor = CSERoute.uuid<ClientMonitorEvent>("/stream/monitor", Elevation.AUTH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ 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"
|
buildconfig = "6.0.10"
|
||||||
|
process = "1.5.1"
|
||||||
|
|
||||||
[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" }
|
||||||
@@ -50,6 +51,7 @@ ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negoti
|
|||||||
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
|
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
|
||||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||||
|
ktor-client-websocket = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
|
|
||||||
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
|
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
|
||||||
ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" }
|
ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" }
|
||||||
@@ -64,6 +66,7 @@ ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref =
|
|||||||
ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" }
|
ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" }
|
||||||
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
|
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
|
||||||
ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" }
|
ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" }
|
||||||
|
ktor-server-websocket = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
|
||||||
|
|
||||||
json = { module = "org.json:json", version.ref = "json" }
|
json = { module = "org.json:json", version.ref = "json" }
|
||||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
|
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
|
||||||
@@ -87,6 +90,7 @@ kolor = { module = "com.kborowy:kolor-picker", version.ref = "kolor-picker" }
|
|||||||
java-keystore = { module = "com.github.javakeyring:java-keyring", version.ref = "java-keystore" }
|
java-keystore = { module = "com.github.javakeyring:java-keyring", version.ref = "java-keystore" }
|
||||||
|
|
||||||
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
|
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
|
||||||
|
process = { module = "com.github.pgreze:kotlin-process", version.ref = "process" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "com.jaytux.phoebench"
|
group = "com.jaytux.phoebench"
|
||||||
version = rootProject.version
|
version = PhoebenchVersion(1, 2, 1, "patch1")
|
||||||
|
|
||||||
|
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
|
||||||
|
throw GradleException("Server version must be at least as high as protocol/common version")
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
@@ -22,7 +25,7 @@ val generateVersion = tasks.register<Task>("serverVersion") {
|
|||||||
package com.jaytux.phoebench.server
|
package com.jaytux.phoebench.server
|
||||||
|
|
||||||
object ServerVersion {
|
object ServerVersion {
|
||||||
const val VERSION="${rootProject.version}"
|
const val VERSION="$version"
|
||||||
}
|
}
|
||||||
""".trimIndent())
|
""".trimIndent())
|
||||||
}
|
}
|
||||||
@@ -53,6 +56,7 @@ dependencies {
|
|||||||
implementation(libs.ktor.server.cors)
|
implementation(libs.ktor.server.cors)
|
||||||
implementation(libs.ktor.server.status.pages)
|
implementation(libs.ktor.server.status.pages)
|
||||||
implementation(libs.ktor.server.sse)
|
implementation(libs.ktor.server.sse)
|
||||||
|
implementation(libs.ktor.server.websocket)
|
||||||
|
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.jaytux.phoebench.server
|
|||||||
import com.jaytux.phoebench.common.AdminEvent
|
import com.jaytux.phoebench.common.AdminEvent
|
||||||
import com.jaytux.phoebench.common.HomeEvent
|
import com.jaytux.phoebench.common.HomeEvent
|
||||||
import com.jaytux.phoebench.common.ProjectEvent
|
import com.jaytux.phoebench.common.ProjectEvent
|
||||||
|
import com.jaytux.phoebench.common.ServerMonitorEvent
|
||||||
|
import io.ktor.client.plugins.api.MonitoringEvent
|
||||||
import io.ktor.util.reflect.typeInfo
|
import io.ktor.util.reflect.typeInfo
|
||||||
import kotlinx.serialization.serializer
|
import kotlinx.serialization.serializer
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -11,6 +13,10 @@ import kotlin.uuid.Uuid
|
|||||||
|
|
||||||
object Buses {
|
object Buses {
|
||||||
private val _projectBuses = ConcurrentHashMap<Uuid, SSEBus<ProjectEvent>>()
|
private val _projectBuses = ConcurrentHashMap<Uuid, SSEBus<ProjectEvent>>()
|
||||||
|
private val _monitorBuses = ConcurrentHashMap<Uuid, SSEBus.MonitorSSEBus<
|
||||||
|
ServerMonitorEvent, ServerMonitorEvent.ApplicationStart, ServerMonitorEvent.Message,
|
||||||
|
ServerMonitorEvent.ApplicationEnd, ServerMonitorEvent.Backlog
|
||||||
|
>>()
|
||||||
|
|
||||||
val homeBus = SSEBus<HomeEvent>(typeOf<HomeEvent>(), serializer<HomeEvent>())
|
val homeBus = SSEBus<HomeEvent>(typeOf<HomeEvent>(), serializer<HomeEvent>())
|
||||||
val adminBus = SSEBus<AdminEvent>(typeOf<AdminEvent>(), serializer<AdminEvent>())
|
val adminBus = SSEBus<AdminEvent>(typeOf<AdminEvent>(), serializer<AdminEvent>())
|
||||||
@@ -19,9 +25,16 @@ object Buses {
|
|||||||
SSEBus<ProjectEvent>(typeOf<ProjectEvent>(), serializer<ProjectEvent>())
|
SSEBus<ProjectEvent>(typeOf<ProjectEvent>(), serializer<ProjectEvent>())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun monitorBus(id: Uuid) = _monitorBuses.computeIfAbsent(id) {
|
||||||
|
SSEBus.MonitorSSEBus(
|
||||||
|
SSEBus(typeOf<ServerMonitorEvent>(), serializer<ServerMonitorEvent>())
|
||||||
|
) { start, events, end -> ServerMonitorEvent.Backlog(start, events, end) }
|
||||||
|
}
|
||||||
|
|
||||||
fun allBuses(): List<SSEBus<*>> {
|
fun allBuses(): List<SSEBus<*>> {
|
||||||
val res = ArrayList<SSEBus<*>>(_projectBuses.size + 2)
|
val res = ArrayList<SSEBus<*>>(_projectBuses.size + _monitorBuses.size + 2)
|
||||||
res.addAll(_projectBuses.values)
|
res.addAll(_projectBuses.values)
|
||||||
|
res.addAll(_monitorBuses.values.map { it.bus })
|
||||||
res.add(homeBus)
|
res.add(homeBus)
|
||||||
res.add(adminBus)
|
res.add(adminBus)
|
||||||
return res
|
return res
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ package com.jaytux.phoebench.server
|
|||||||
|
|
||||||
import com.jaytux.phoebench.common.*
|
import com.jaytux.phoebench.common.*
|
||||||
import com.jaytux.phoebench.server.db.DB
|
import com.jaytux.phoebench.server.db.DB
|
||||||
|
import com.jaytux.phoebench.server.db.Meta
|
||||||
|
import com.jaytux.phoebench.server.db.Metas
|
||||||
|
import com.jaytux.phoebench.server.db.Project
|
||||||
import com.jaytux.phoebench.server.db.User
|
import com.jaytux.phoebench.server.db.User
|
||||||
import com.jaytux.phoebench.server.handlers.*
|
import com.jaytux.phoebench.server.handlers.*
|
||||||
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject
|
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject
|
||||||
|
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProjectCSE
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.WebsocketContentConverter
|
||||||
|
import io.ktor.serialization.deserialize
|
||||||
|
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.auth.*
|
import io.ktor.server.auth.*
|
||||||
@@ -19,32 +26,45 @@ import io.ktor.server.request.*
|
|||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
import io.ktor.server.sse.*
|
import io.ktor.server.sse.*
|
||||||
|
import io.ktor.server.websocket.WebSockets
|
||||||
|
import io.ktor.server.websocket.webSocket
|
||||||
|
import io.ktor.websocket.CloseReason
|
||||||
|
import io.ktor.websocket.close
|
||||||
|
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import kotlin.time.Clock
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
fun Application.module() {
|
fun Application.module() {
|
||||||
DB.db
|
DB.db
|
||||||
println("Entering module for PhoeBench Server version ${ServerVersion.VERSION}...")
|
println("Entering module for PhoeBench Server version ${ServerVersion.VERSION}...")
|
||||||
|
|
||||||
|
if(Meta.single.revision < Metas.CURRENT_REVISION) {
|
||||||
|
DB.migrate(Meta.single.revision, Metas.CURRENT_REVISION)
|
||||||
|
}
|
||||||
|
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json()
|
json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
install(CORS) {
|
install(CORS) {
|
||||||
|
// -- methods --
|
||||||
allowMethod(HttpMethod.Options)
|
allowMethod(HttpMethod.Options)
|
||||||
allowMethod(HttpMethod.Delete)
|
allowMethod(HttpMethod.Delete)
|
||||||
allowMethod(HttpMethod.Patch)
|
allowMethod(HttpMethod.Patch)
|
||||||
allowMethod(HttpMethod.Get)
|
allowMethod(HttpMethod.Get)
|
||||||
allowMethod(HttpMethod.Post)
|
allowMethod(HttpMethod.Post)
|
||||||
|
// -- headers --
|
||||||
allowHeader(HttpHeaders.Accept)
|
allowHeader(HttpHeaders.Accept)
|
||||||
|
allowHeader(HttpHeaders.Pragma)
|
||||||
allowHeader(HttpHeaders.Authorization)
|
allowHeader(HttpHeaders.Authorization)
|
||||||
allowHeader(HttpHeaders.ContentType)
|
allowHeader(HttpHeaders.ContentType)
|
||||||
|
allowHeader(HttpHeaders.CacheControl)
|
||||||
|
allowHeader(HttpHeaders.LastEventID)
|
||||||
|
// -- exposed headers --
|
||||||
exposeHeader(HttpHeaders.ContentType)
|
exposeHeader(HttpHeaders.ContentType)
|
||||||
allowCredentials = true
|
allowCredentials = true
|
||||||
allowNonSimpleContentTypes = true
|
allowNonSimpleContentTypes = true
|
||||||
@@ -67,6 +87,12 @@ fun Application.module() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install(SSE) {}
|
||||||
|
|
||||||
|
install(WebSockets) {
|
||||||
|
contentConverter = KotlinxWebsocketSerializationConverter(Json)
|
||||||
|
}
|
||||||
|
|
||||||
install(StatusPages) {
|
install(StatusPages) {
|
||||||
status(HttpStatusCode.Forbidden) { call, status ->
|
status(HttpStatusCode.Forbidden) { call, status ->
|
||||||
call.respond(status, ErrorResponse("Access Forbidden: CORS failed."))
|
call.respond(status, ErrorResponse("Access Forbidden: CORS failed."))
|
||||||
@@ -120,11 +146,17 @@ fun Application.module() {
|
|||||||
deleteAdmin(Routes.Auth.User.delete, AuthHandler::deleteUser)
|
deleteAdmin(Routes.Auth.User.delete, AuthHandler::deleteUser)
|
||||||
|
|
||||||
getAuth(Routes.home, ProjectHandler::home)
|
getAuth(Routes.home, ProjectHandler::home)
|
||||||
|
postAuth(Routes.migrate0To1, ProjectHandler::migrate01)
|
||||||
|
|
||||||
postAuth(Routes.Project.new, ProjectHandler::createProject)
|
postAuth(Routes.Project.new, ProjectHandler::createProject)
|
||||||
getAuth(Routes.Project.get, ProjectHandler::getProject)
|
getAuth(Routes.Project.get, ProjectHandler::getProject)
|
||||||
patchAuth(Routes.Project.update, ProjectHandler::updateProject)
|
patchAuth(Routes.Project.update, ProjectHandler::updateProject)
|
||||||
deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject)
|
deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject)
|
||||||
|
deleteAuth(Routes.Project.rmMonitor, ProjectHandler::deleteMonitor)
|
||||||
|
|
||||||
|
postAuth(Routes.Benchmark.new, ProjectHandler::createBenchmark)
|
||||||
|
patchAuth(Routes.Benchmark.update, ProjectHandler::updateBenchmark)
|
||||||
|
deleteAuth(Routes.Benchmark.delete, ProjectHandler::deleteBenchmark)
|
||||||
|
|
||||||
postAuth(Routes.Version.new, ProjectHandler::createVersion)
|
postAuth(Routes.Version.new, ProjectHandler::createVersion)
|
||||||
patchAuth(Routes.Version.update, ProjectHandler::updateVersion)
|
patchAuth(Routes.Version.update, ProjectHandler::updateVersion)
|
||||||
@@ -133,13 +165,37 @@ fun Application.module() {
|
|||||||
postAuth(Routes.Entry.new, ProjectHandler::createEntry)
|
postAuth(Routes.Entry.new, ProjectHandler::createEntry)
|
||||||
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
|
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
|
||||||
|
|
||||||
sseAuth(Routes.SSE.home, { _, _ -> }) { _, _ -> Buses.homeBus }
|
sseAuth(Routes.SSE.home, { _, _ -> }) { _, _, _ -> Buses.homeBus }
|
||||||
sseAdmin(Routes.SSE.admin) { _, _ -> Buses.adminBus }
|
sseAdmin(Routes.SSE.admin) { _, _, _ -> Buses.adminBus }
|
||||||
sseAuth(Routes.SSE.projectSpecific,
|
sseAuth(Routes.SSE.projectSpecific,
|
||||||
{ user, uuid ->
|
{ user, uuid ->
|
||||||
transaction { accessibleProject(user, uuid, false) }
|
transaction { accessibleProject(user, uuid, false) }
|
||||||
}
|
}
|
||||||
) { _, id -> Buses.projectBus(id) }
|
) { _, id, _ -> Buses.projectBus(id) }
|
||||||
|
sseAuth(Routes.SSE.monitor,
|
||||||
|
{ user, uuid ->
|
||||||
|
transaction { accessibleProject(user, uuid, true) }
|
||||||
|
}
|
||||||
|
) { _, id, sender ->
|
||||||
|
val bus = Buses.monitorBus(id)
|
||||||
|
val backlog = bus.onConnect()
|
||||||
|
sender(bus.bus.serializer, backlog)
|
||||||
|
bus.bus
|
||||||
|
}
|
||||||
|
|
||||||
|
cseAuth(Routes.CSE.monitor,
|
||||||
|
{ id, user -> transaction { accessibleProjectCSE(user, id, true) } },
|
||||||
|
ProjectHandler::monitorSetup, ProjectHandler::monitorTeardown) { _, _, bus, event ->
|
||||||
|
try {
|
||||||
|
when(event) {
|
||||||
|
is ClientMonitorEvent.ApplicationFinished -> bus.end(ServerMonitorEvent.ApplicationEnd(Clock.System.now(), event.exitCode))
|
||||||
|
is ClientMonitorEvent.Message -> bus.event(ServerMonitorEvent.Message(Clock.System.now(), event.msg, event.options, event.stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(e: IllegalStateException) {
|
||||||
|
throw RouteError.CSERouteError("Could not deliver message: ${e.message ?: "unknown error"}", CloseReasons.CONFLICT)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get("{...}") {
|
get("{...}") {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.jaytux.phoebench.server
|
||||||
|
|
||||||
|
class MutableBackLog<TStart, TEvent, TEnd> {
|
||||||
|
var start: TStart? = null
|
||||||
|
private set
|
||||||
|
private val _events = mutableListOf<TEvent>()
|
||||||
|
val events = _events.immutable()
|
||||||
|
var end: TEnd? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun registerStart(event: TStart) {
|
||||||
|
if(start != null) throw IllegalStateException("Start is already set.")
|
||||||
|
start = event
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: TEvent) {
|
||||||
|
if(start == null) throw IllegalStateException("Start is not set yet.")
|
||||||
|
if(end != null) throw IllegalStateException("End is already set.")
|
||||||
|
_events += event
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerEnd(event: TEnd) {
|
||||||
|
if(start == null) throw IllegalStateException("Start is not set yet.")
|
||||||
|
if(end != null) throw IllegalStateException("End is already set.")
|
||||||
|
end = event
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
start = null
|
||||||
|
_events.clear()
|
||||||
|
end = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isRunning() = start != null && end == null
|
||||||
|
}
|
||||||
@@ -61,4 +61,32 @@ class SSEBus<T>(private val _containedType: KType, val serializer: KSerializer<T
|
|||||||
map
|
map
|
||||||
}.forEach { it.second.emit(Cancellation.error()) }
|
}.forEach { it.second.emit(Cancellation.error()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MonitorSSEBus<TSuper, TStart : TSuper, TEvent : TSuper, TEnd: TSuper, TBacklog: TSuper>(
|
||||||
|
val bus: SSEBus<TSuper>, val backlog: MutableBackLog<TStart, TEvent, TEnd> = MutableBackLog(),
|
||||||
|
val mkBacklog: (start: TStart?, events: List<TEvent>, end: TEnd?) -> TBacklog
|
||||||
|
) {
|
||||||
|
val start: TStart? get() = backlog.start
|
||||||
|
val events: List<TEvent> get() = backlog.events
|
||||||
|
val end: TEnd? get() = backlog.end
|
||||||
|
|
||||||
|
suspend fun start(start: TStart) {
|
||||||
|
backlog.registerStart(start)
|
||||||
|
bus.sendAll(start)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun event(event: TEvent) {
|
||||||
|
backlog.onEvent(event)
|
||||||
|
bus.sendAll(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun end(end: TEnd) {
|
||||||
|
backlog.registerEnd(end)
|
||||||
|
bus.sendAll(end)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun onConnect() = mkBacklog(start, events, end)
|
||||||
|
|
||||||
|
fun isRunning() = backlog.isRunning()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -22,3 +22,7 @@ fun nowPlus(time: Int, unit: DateTimeUnit): Instant {
|
|||||||
val now = Clock.System.now()
|
val now = Clock.System.now()
|
||||||
return now.plus(time, unit, systemTZ)
|
return now.plus(time, unit, systemTZ)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
|
||||||
|
|
||||||
|
fun <T> MutableList<T>.immutable(): List<T> = this
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.jaytux.phoebench.server.db
|
package com.jaytux.phoebench.server.db
|
||||||
|
|
||||||
import com.jaytux.phoebench.server.DotEnv
|
import com.jaytux.phoebench.server.DotEnv
|
||||||
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.jdbc.Database
|
import org.jetbrains.exposed.v1.jdbc.Database
|
||||||
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
|
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
|
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
|
||||||
|
|
||||||
object DB {
|
object DB {
|
||||||
@@ -17,7 +19,7 @@ object DB {
|
|||||||
transaction {
|
transaction {
|
||||||
SchemaUtils.create(Users, Invites, RefreshTokens, Projects, Versions, Entries)
|
SchemaUtils.create(Users, Invites, RefreshTokens, Projects, Versions, Entries)
|
||||||
|
|
||||||
val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Versions, Entries)
|
val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Versions, Entries, Metas, EntriesV2, Benchmarks)
|
||||||
try {
|
try {
|
||||||
migration.forEach {
|
migration.forEach {
|
||||||
exec(it)
|
exec(it)
|
||||||
@@ -29,4 +31,29 @@ object DB {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun migrate(from: Int, to: Int) = when(from) {
|
||||||
|
0 -> if(to == 1) migrate0To1() else null
|
||||||
|
else -> null
|
||||||
|
} ?: throw IllegalArgumentException("Migration $from -> $to is not defined")
|
||||||
|
|
||||||
|
private fun migrate0To1() {
|
||||||
|
transaction {
|
||||||
|
Users.update(where = { Users.isAdmin eq true }) { it[benchmarkLimit] = -1 }
|
||||||
|
Users.update(where = { Users.isAdmin eq false }) { it.update(benchmarkLimit, projectLimit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction {
|
||||||
|
User.all().forEach {
|
||||||
|
if(it.projects.count() == 0L) {
|
||||||
|
it.projectVersion = Projects.CURRENT_VERSION
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction {
|
||||||
|
Meta.single.revision = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
package com.jaytux.phoebench.server.db
|
package com.jaytux.phoebench.server.db
|
||||||
|
|
||||||
|
import org.jetbrains.exposed.v1.core.Transaction
|
||||||
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||||
import org.jetbrains.exposed.v1.dao.Entity
|
import org.jetbrains.exposed.v1.dao.Entity
|
||||||
import org.jetbrains.exposed.v1.dao.EntityClass
|
import org.jetbrains.exposed.v1.dao.EntityClass
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
class Meta(id: EntityID<Int>) : Entity<Int>(id) {
|
||||||
|
companion object : EntityClass<Int, Meta>(Metas) {
|
||||||
|
val single by lazy {
|
||||||
|
transaction {
|
||||||
|
val got = all().limit(1).toList()
|
||||||
|
got.firstOrNull() ?: new {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var revision by Metas.revision
|
||||||
|
}
|
||||||
|
|
||||||
class User(id: EntityID<Uuid>) : Entity<Uuid>(id) {
|
class User(id: EntityID<Uuid>) : Entity<Uuid>(id) {
|
||||||
companion object : EntityClass<Uuid, User>(Users)
|
companion object : EntityClass<Uuid, User>(Users)
|
||||||
|
|
||||||
@@ -13,6 +28,8 @@ class User(id: EntityID<Uuid>) : Entity<Uuid>(id) {
|
|||||||
var isAdmin by Users.isAdmin
|
var isAdmin by Users.isAdmin
|
||||||
var isOwner by Users.isOwner
|
var isOwner by Users.isOwner
|
||||||
var projectLimit by Users.projectLimit
|
var projectLimit by Users.projectLimit
|
||||||
|
var benchmarkLimit by Users.benchmarkLimit
|
||||||
|
var projectVersion by Users.projectsVersion
|
||||||
|
|
||||||
val projects by Project referrersOn Projects.ownerId
|
val projects by Project referrersOn Projects.ownerId
|
||||||
val sessions by RefreshToken referrersOn RefreshTokens.userId
|
val sessions by RefreshToken referrersOn RefreshTokens.userId
|
||||||
@@ -42,8 +59,18 @@ class Project(id: EntityID<Uuid>) : Entity<Uuid>(id) {
|
|||||||
var isPublic by Projects.isPublic
|
var isPublic by Projects.isPublic
|
||||||
|
|
||||||
var owner by User referencedOn Projects.ownerId
|
var owner by User referencedOn Projects.ownerId
|
||||||
val labels by Version referrersOn Versions.projectId
|
val versions by Version referrersOn Versions.projectId
|
||||||
val entries by Entry referrersOn Entries.projectId
|
val benchmarks by Benchmark referrersOn Benchmarks.projectId
|
||||||
|
}
|
||||||
|
|
||||||
|
class Benchmark(id: EntityID<Uuid>) : Entity<Uuid>(id) {
|
||||||
|
companion object : EntityClass<Uuid, Benchmark>(Benchmarks)
|
||||||
|
|
||||||
|
var projectId by Benchmarks.projectId
|
||||||
|
var name by Benchmarks.name
|
||||||
|
|
||||||
|
val project by Project referencedOn Benchmarks.projectId
|
||||||
|
val entries by EntryV2 referrersOn EntriesV2.benchmarkId
|
||||||
}
|
}
|
||||||
|
|
||||||
class Version(id: EntityID<Uuid>) : Entity<Uuid>(id) {
|
class Version(id: EntityID<Uuid>) : Entity<Uuid>(id) {
|
||||||
@@ -72,3 +99,19 @@ class Entry(id: EntityID<Uuid>) : Entity<Uuid>(id) {
|
|||||||
var project by Project referencedOn Entries.projectId
|
var project by Project referencedOn Entries.projectId
|
||||||
var version by Version referencedOn Entries.versionId
|
var version by Version referencedOn Entries.versionId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class EntryV2(id: EntityID<Uuid>) : Entity<Uuid>(id) {
|
||||||
|
companion object : EntityClass<Uuid, EntryV2>(EntriesV2)
|
||||||
|
|
||||||
|
var benchmarkId by EntriesV2.benchmarkId
|
||||||
|
var versionId by EntriesV2.versionId
|
||||||
|
var timestamp by EntriesV2.timestamp
|
||||||
|
var warmups by EntriesV2.warmups
|
||||||
|
var measurements by EntriesV2.measurements
|
||||||
|
var unit by EntriesV2.unit
|
||||||
|
var input by EntriesV2.input
|
||||||
|
var hardware by EntriesV2.hardware
|
||||||
|
|
||||||
|
var benchmark by Benchmark referencedOn EntriesV2.benchmarkId
|
||||||
|
var version by Version referencedOn EntriesV2.versionId
|
||||||
|
}
|
||||||
@@ -2,16 +2,26 @@ package com.jaytux.phoebench.server.db
|
|||||||
|
|
||||||
import com.jaytux.phoebench.common.TimeUnit
|
import com.jaytux.phoebench.common.TimeUnit
|
||||||
import org.jetbrains.exposed.v1.core.ReferenceOption
|
import org.jetbrains.exposed.v1.core.ReferenceOption
|
||||||
|
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
||||||
import org.jetbrains.exposed.v1.core.dao.id.UuidTable
|
import org.jetbrains.exposed.v1.core.dao.id.UuidTable
|
||||||
import org.jetbrains.exposed.v1.datetime.timestamp
|
import org.jetbrains.exposed.v1.datetime.timestamp
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
object Metas : IntIdTable() {
|
||||||
|
const val CURRENT_REVISION = 1
|
||||||
|
|
||||||
|
val revision = integer("revision").default(0)
|
||||||
|
}
|
||||||
|
|
||||||
object Users : UuidTable() {
|
object Users : UuidTable() {
|
||||||
val username = varchar("username", 255).uniqueIndex()
|
val username = varchar("username", 255).uniqueIndex()
|
||||||
val password = varchar("password", 255)
|
val password = varchar("password", 255)
|
||||||
val isAdmin = bool("is_admin").default(false)
|
val isAdmin = bool("is_admin").default(false)
|
||||||
val isOwner = bool("is_owner").default(false)
|
val isOwner = bool("is_owner").default(false)
|
||||||
val projectLimit = integer("project_limit").default(1)
|
val projectLimit = integer("project_limit").default(1)
|
||||||
|
val benchmarkLimit = integer("benchmark_limit").default(1)
|
||||||
|
|
||||||
|
val projectsVersion = integer("project_version").default(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
object Invites : UuidTable() {
|
object Invites : UuidTable() {
|
||||||
@@ -25,6 +35,8 @@ object RefreshTokens : UuidTable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
object Projects : UuidTable() {
|
object Projects : UuidTable() {
|
||||||
|
const val CURRENT_VERSION = 1
|
||||||
|
|
||||||
val ownerId = reference("owner_id", Users, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
|
val ownerId = reference("owner_id", Users, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
|
||||||
val name = varchar("name", 255)
|
val name = varchar("name", 255)
|
||||||
val isPublic = bool("is_public").default(false)
|
val isPublic = bool("is_public").default(false)
|
||||||
@@ -34,6 +46,15 @@ object Projects : UuidTable() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object Benchmarks : UuidTable() {
|
||||||
|
val projectId = reference("project_id", Projects, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
|
||||||
|
val name = varchar("name", 255)
|
||||||
|
|
||||||
|
init {
|
||||||
|
uniqueIndex(projectId, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
object Versions : UuidTable() {
|
object Versions : UuidTable() {
|
||||||
val label = varchar("label", 255)
|
val label = varchar("label", 255)
|
||||||
val color = varchar("color", 7)
|
val color = varchar("color", 7)
|
||||||
@@ -51,3 +72,14 @@ object Entries : UuidTable() {
|
|||||||
val input = varchar("input", 255)
|
val input = varchar("input", 255)
|
||||||
val hardware = varchar("hardware", 255)
|
val hardware = varchar("hardware", 255)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object EntriesV2 : UuidTable() {
|
||||||
|
val benchmarkId = reference("benchmark_id", Benchmarks, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
|
||||||
|
val versionId = reference("version_id", Versions, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
|
||||||
|
val timestamp = timestamp("timestamp").default(Clock.System.now())
|
||||||
|
val warmups = list<Float>("warmups")
|
||||||
|
val measurements = list<Float>("measurements")
|
||||||
|
val unit = enumeration<TimeUnit>("unit")
|
||||||
|
val input = varchar("input", 255)
|
||||||
|
val hardware = varchar("hardware", 255)
|
||||||
|
}
|
||||||
@@ -4,18 +4,19 @@ import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrapped
|
|||||||
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAdmin
|
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAdmin
|
||||||
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAuth
|
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAuth
|
||||||
import com.jaytux.phoebench.common.ApiRoute
|
import com.jaytux.phoebench.common.ApiRoute
|
||||||
import com.jaytux.phoebench.common.Either
|
import com.jaytux.phoebench.common.CSERoute
|
||||||
|
import com.jaytux.phoebench.common.CloseReasons
|
||||||
import com.jaytux.phoebench.common.Elevation
|
import com.jaytux.phoebench.common.Elevation
|
||||||
import com.jaytux.phoebench.common.EmptyRequest
|
import com.jaytux.phoebench.common.EmptyRequest
|
||||||
import com.jaytux.phoebench.common.ErrorResponse
|
import com.jaytux.phoebench.common.ErrorResponse
|
||||||
import com.jaytux.phoebench.common.SSERoute
|
import com.jaytux.phoebench.common.SSERoute
|
||||||
import com.jaytux.phoebench.common.foldSuspend
|
import com.jaytux.phoebench.common.foldSuspend
|
||||||
import com.jaytux.phoebench.server.Auth.setup
|
|
||||||
import com.jaytux.phoebench.server.SSEBus
|
import com.jaytux.phoebench.server.SSEBus
|
||||||
import com.jaytux.phoebench.server.db.User
|
import com.jaytux.phoebench.server.db.User
|
||||||
import com.jaytux.phoebench.server.handlers.RouteError
|
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.serialization.deserialize
|
||||||
|
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
|
||||||
import io.ktor.server.application.ApplicationCall
|
import io.ktor.server.application.ApplicationCall
|
||||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||||
import io.ktor.server.auth.principal
|
import io.ktor.server.auth.principal
|
||||||
@@ -31,10 +32,15 @@ import io.ktor.server.routing.delete
|
|||||||
import io.ktor.server.routing.patch
|
import io.ktor.server.routing.patch
|
||||||
import io.ktor.server.sse.heartbeat
|
import io.ktor.server.sse.heartbeat
|
||||||
import io.ktor.server.sse.sse
|
import io.ktor.server.sse.sse
|
||||||
|
import io.ktor.server.websocket.webSocket
|
||||||
import io.ktor.sse.ServerSentEvent
|
import io.ktor.sse.ServerSentEvent
|
||||||
import io.ktor.util.reflect.typeInfo
|
import io.ktor.util.reflect.typeInfo
|
||||||
import io.ktor.utils.io.CancellationException
|
import io.ktor.utils.io.CancellationException
|
||||||
|
import io.ktor.websocket.CloseReason
|
||||||
|
import io.ktor.websocket.close
|
||||||
|
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
@@ -165,7 +171,7 @@ inline fun <reified TReq: Any, reified TRes: Any> Route.patchAdmin(api: ApiRoute
|
|||||||
|
|
||||||
inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrapSSE(
|
inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrapSSE(
|
||||||
api: SSERoute<TParams, TEvent>, noinline extra: suspend (ApplicationCall, TParams) -> TInter,
|
api: SSERoute<TParams, TEvent>, noinline extra: suspend (ApplicationCall, TParams) -> TInter,
|
||||||
noinline prepare: suspend (TInter, TParams) -> SSEBus<TEvent>,
|
noinline prepare: suspend (TInter, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>,
|
||||||
noinline extract: suspend (SSEBus<TEvent>, TInter, TParams) -> SharedFlow<TFlow>,
|
noinline extract: suspend (SSEBus<TEvent>, TInter, TParams) -> SharedFlow<TFlow>,
|
||||||
noinline handler: suspend (TFlow, sender: suspend (TEvent) -> Unit) -> Unit,
|
noinline handler: suspend (TFlow, sender: suspend (TEvent) -> Unit) -> Unit,
|
||||||
noinline onCancel: suspend (SSEBus<TEvent>, TInter, TParams, CancellationException) -> Unit
|
noinline onCancel: suspend (SSEBus<TEvent>, TInter, TParams, CancellationException) -> Unit
|
||||||
@@ -182,11 +188,14 @@ inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrap
|
|||||||
)
|
)
|
||||||
|
|
||||||
val inter = extra(call, params)
|
val inter = extra(call, params)
|
||||||
val bus = prepare(inter, params)
|
val bus = prepare(inter, params) { serializer, event -> send(ServerSentEvent(data = Json.encodeToString(serializer, event))) }
|
||||||
val stream = extract(bus, inter, params)
|
val stream = extract(bus, inter, params)
|
||||||
try {
|
try {
|
||||||
stream.collect {
|
stream.collect {
|
||||||
handler(it) { ev -> send(ServerSentEvent(data = Json.encodeToString(bus.serializer, ev))) }
|
handler(it) { ev ->
|
||||||
|
println("[SSE ${api.pattern}]: sending event!")
|
||||||
|
send(ServerSentEvent(data = Json.encodeToString(bus.serializer, ev)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch(e: CancellationException) {
|
catch(e: CancellationException) {
|
||||||
@@ -214,7 +223,7 @@ inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrap
|
|||||||
inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
|
inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
|
||||||
api: SSERoute<TParams, TEvent>,
|
api: SSERoute<TParams, TEvent>,
|
||||||
noinline verifyUser: suspend (User, TParams) -> Unit,
|
noinline verifyUser: suspend (User, TParams) -> Unit,
|
||||||
noinline prepare: suspend (User, TParams) -> SSEBus<TEvent>
|
noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
|
||||||
) = wrapSSE(api,
|
) = wrapSSE(api,
|
||||||
extra = { call, params ->
|
extra = { call, params ->
|
||||||
val principal = call.principal<JWTPrincipal>()
|
val principal = call.principal<JWTPrincipal>()
|
||||||
@@ -239,23 +248,123 @@ inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
|
|||||||
onCancel = { bus, user, _, _ -> bus.disconnect(user.id.value) }
|
onCancel = { bus, user, _, _ -> bus.disconnect(user.id.value) }
|
||||||
)
|
)
|
||||||
|
|
||||||
inline fun <reified TParams: Any, reified TEvent: Any> Route.sse(api: SSERoute<TParams, TEvent>, noinline setup: suspend (TParams) -> SSEBus<TEvent>) =
|
inline fun <reified TParams: Any, reified TEvent: Any> Route.sse(api: SSERoute<TParams, TEvent>, noinline prepare: suspend (TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>) {
|
||||||
wrapSSE(api,
|
if(api.elevation != Elevation.UN_AUTH) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
|
||||||
|
wrapSSE(
|
||||||
|
api,
|
||||||
extra = { _, _ -> },
|
extra = { _, _ -> },
|
||||||
prepare = { _, params -> setup(params) },
|
prepare = { _, params, sender -> prepare(params, sender) },
|
||||||
extract = { bus, _, _ -> bus.unRegistered() },
|
extract = { bus, _, _ -> bus.unRegistered() },
|
||||||
handler = { it, sender -> sender(it) },
|
handler = { it, sender -> sender(it) },
|
||||||
onCancel = { _, _, _, _ -> }
|
onCancel = { _, _, _, _ -> }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAuth(api: SSERoute<TParams, TEvent>,
|
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAuth(api: SSERoute<TParams, TEvent>,
|
||||||
noinline verifyUser: suspend (User, TParams) -> Unit, noinline setup: suspend (User, TParams) -> SSEBus<TEvent>
|
noinline verifyUser: suspend (User, TParams) -> Unit, noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
|
||||||
) = wrapAuthSSE(api, verifyUser, setup)
|
) {
|
||||||
|
if(api.elevation != Elevation.AUTH) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
|
||||||
|
wrapAuthSSE(api, verifyUser, prepare)
|
||||||
|
}
|
||||||
|
|
||||||
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAdmin(api: SSERoute<TParams, TEvent>,
|
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAdmin(api: SSERoute<TParams, TEvent>,
|
||||||
noinline setup: suspend (User, TParams) -> SSEBus<TEvent>
|
noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
|
||||||
) = wrapAuthSSE(api, { user, _ ->
|
) {
|
||||||
|
if(api.elevation != Elevation.ADMIN) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
|
||||||
|
wrapAuthSSE(api, { user, _ ->
|
||||||
if(!user.isAdmin) {
|
if(!user.isAdmin) {
|
||||||
throw RouteError("Admin access required", HttpStatusCode.Forbidden)
|
throw RouteError("Admin access required", HttpStatusCode.Forbidden)
|
||||||
}
|
}
|
||||||
}, setup)
|
}, prepare)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified TParams: Any, reified TEvent: Any, TInter, TExtra> Route.wrapCSE(
|
||||||
|
api: CSERoute<TParams, TEvent>,
|
||||||
|
noinline extra: suspend (ApplicationCall, TParams) -> TInter,
|
||||||
|
noinline setup: suspend (TParams, TInter) -> TExtra,
|
||||||
|
noinline onClose: suspend (TParams, TInter, TExtra) -> Unit,
|
||||||
|
noinline handler: suspend (TParams, TInter, TExtra, TEvent) -> Unit
|
||||||
|
) {
|
||||||
|
webSocket(api.pattern) {
|
||||||
|
try {
|
||||||
|
val params = api.extractParams(call.parameters) ?:
|
||||||
|
throw RouteError.CSERouteError("Missing or malformed parameters for CSE ${api.pattern}", CloseReasons.INVALID_REQUEST)
|
||||||
|
val inter = extra(call, params)
|
||||||
|
|
||||||
|
val converter = KotlinxWebsocketSerializationConverter(Json)
|
||||||
|
val extra = setup(params, inter)
|
||||||
|
try {
|
||||||
|
for (frame in incoming) {
|
||||||
|
handler(params, inter, extra, converter.deserialize<TEvent>(frame))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
onClose(params, inter, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(e: RouteError.CSERouteError) {
|
||||||
|
close(CloseReason(e.code, e.message ?: "Unknown error"))
|
||||||
|
}
|
||||||
|
catch(e: Exception) {
|
||||||
|
close(CloseReason(CloseReason.Codes.INTERNAL_ERROR, e.message ?: "Unknown error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.wrapAuthCSE(
|
||||||
|
api: CSERoute<TParams, TEvent>,
|
||||||
|
noinline verifyUser: suspend (TParams, User) -> Unit,
|
||||||
|
noinline setup: suspend (TParams, User) -> TExtra,
|
||||||
|
noinline onClose: suspend (TParams, User, TExtra) -> Unit,
|
||||||
|
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
|
||||||
|
) = wrapCSE<TParams, TEvent, User, TExtra>(
|
||||||
|
api = api,
|
||||||
|
extra = { call, params ->
|
||||||
|
val principal = call.principal<JWTPrincipal>()
|
||||||
|
val userId = principal?.payload?.getClaim(com.jaytux.phoebench.common.Auth.JWT_CLAIM)?.asString()
|
||||||
|
?: throw RouteError.CSERouteError("Missing user claim", CloseReasons.NOT_AUTHORIZED)
|
||||||
|
val user = transaction {
|
||||||
|
User.findById(Uuid.parse(userId)) ?: throw RouteError.CSERouteError(
|
||||||
|
"User not found",
|
||||||
|
CloseReasons.NOT_AUTHORIZED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
verifyUser(params, user)
|
||||||
|
user
|
||||||
|
},
|
||||||
|
setup = setup,
|
||||||
|
onClose = onClose,
|
||||||
|
handler = handler
|
||||||
|
)
|
||||||
|
|
||||||
|
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cse(
|
||||||
|
api: CSERoute<TParams, TEvent>,
|
||||||
|
noinline setup: suspend (TParams) -> TExtra,
|
||||||
|
noinline onClose: suspend (TParams, TExtra) -> Unit,
|
||||||
|
noinline handler: suspend (TParams, TExtra, TEvent) -> Unit
|
||||||
|
) {
|
||||||
|
if(api.elevation != Elevation.UN_AUTH) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
|
||||||
|
wrapCSE(api, { _, _ -> }, { it, _ -> setup(it) }, { p, _, extra -> onClose(p, extra) }) { params, _, extra, event -> handler(params, extra, event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cseAuth(
|
||||||
|
api: CSERoute<TParams, TEvent>, noinline verifyUser: suspend (TParams, User) -> Unit,
|
||||||
|
noinline setup: suspend (TParams, User) -> TExtra, noinline onClose: suspend (TParams, User, TExtra) -> Unit,
|
||||||
|
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
|
||||||
|
) {
|
||||||
|
if(api.elevation != Elevation.AUTH) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
|
||||||
|
wrapAuthCSE(api, verifyUser, setup, onClose, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cseAdmin(
|
||||||
|
api: CSERoute<TParams, TEvent>, noinline setup: suspend (TParams, User) -> TExtra,
|
||||||
|
noinline onClose: suspend (TParams, User, TExtra) -> Unit,
|
||||||
|
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
|
||||||
|
) {
|
||||||
|
if(api.elevation != Elevation.ADMIN) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
|
||||||
|
wrapAuthCSE(api, { _, user ->
|
||||||
|
if(!user.isAdmin) {
|
||||||
|
throw RouteError.CSERouteError("Admin access required", CloseReasons.NOT_AUTHORIZED)
|
||||||
|
}
|
||||||
|
}, setup, onClose, handler)
|
||||||
|
}
|
||||||
@@ -1,22 +1,35 @@
|
|||||||
package com.jaytux.phoebench.server.handlers
|
package com.jaytux.phoebench.server.handlers
|
||||||
|
|
||||||
|
import com.jaytux.phoebench.common.BenchmarkRequest
|
||||||
|
import com.jaytux.phoebench.common.BenchmarkResponse
|
||||||
|
import com.jaytux.phoebench.common.BenchmarkSummary
|
||||||
|
import com.jaytux.phoebench.common.ClientMonitorEvent
|
||||||
|
import com.jaytux.phoebench.common.CloseReasons
|
||||||
import com.jaytux.phoebench.common.EmptyRequest
|
import com.jaytux.phoebench.common.EmptyRequest
|
||||||
import com.jaytux.phoebench.common.EmptyResponse
|
import com.jaytux.phoebench.common.EmptyResponse
|
||||||
import com.jaytux.phoebench.common.EntryRequest
|
import com.jaytux.phoebench.common.EntryRequest
|
||||||
import com.jaytux.phoebench.common.EntryResponse
|
import com.jaytux.phoebench.common.EntryResponse
|
||||||
import com.jaytux.phoebench.common.HomeEvent
|
import com.jaytux.phoebench.common.HomeEvent
|
||||||
import com.jaytux.phoebench.common.HomeResponse
|
import com.jaytux.phoebench.common.HomeResponse
|
||||||
|
import com.jaytux.phoebench.common.Migrate0To1Request
|
||||||
import com.jaytux.phoebench.common.VersionRequest
|
import com.jaytux.phoebench.common.VersionRequest
|
||||||
import com.jaytux.phoebench.common.VersionResponse
|
import com.jaytux.phoebench.common.VersionResponse
|
||||||
import com.jaytux.phoebench.common.NamedID
|
import com.jaytux.phoebench.common.NamedID
|
||||||
|
import com.jaytux.phoebench.common.PartialBenchmarkRequest
|
||||||
import com.jaytux.phoebench.common.PartialProjectRequest
|
import com.jaytux.phoebench.common.PartialProjectRequest
|
||||||
import com.jaytux.phoebench.common.PartialVersionRequest
|
import com.jaytux.phoebench.common.PartialVersionRequest
|
||||||
import com.jaytux.phoebench.common.ProjectEvent
|
import com.jaytux.phoebench.common.ProjectEvent
|
||||||
import com.jaytux.phoebench.common.ProjectRequest
|
import com.jaytux.phoebench.common.ProjectRequest
|
||||||
import com.jaytux.phoebench.common.ProjectResponse
|
import com.jaytux.phoebench.common.ProjectResponse
|
||||||
|
import com.jaytux.phoebench.common.ServerMonitorEvent
|
||||||
import com.jaytux.phoebench.server.Buses
|
import com.jaytux.phoebench.server.Buses
|
||||||
|
import com.jaytux.phoebench.server.SSEBus
|
||||||
|
import com.jaytux.phoebench.server.app
|
||||||
|
import com.jaytux.phoebench.server.db.Benchmark
|
||||||
|
import com.jaytux.phoebench.server.db.Benchmarks
|
||||||
import com.jaytux.phoebench.server.db.Entries
|
import com.jaytux.phoebench.server.db.Entries
|
||||||
import com.jaytux.phoebench.server.db.Entry
|
import com.jaytux.phoebench.server.db.Entry
|
||||||
|
import com.jaytux.phoebench.server.db.EntryV2
|
||||||
import com.jaytux.phoebench.server.db.Version
|
import com.jaytux.phoebench.server.db.Version
|
||||||
import com.jaytux.phoebench.server.db.Versions
|
import com.jaytux.phoebench.server.db.Versions
|
||||||
import com.jaytux.phoebench.server.db.Project
|
import com.jaytux.phoebench.server.db.Project
|
||||||
@@ -27,8 +40,14 @@ import io.ktor.http.HttpStatusCode
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.exposed.v1.core.SortOrder
|
import org.jetbrains.exposed.v1.core.SortOrder
|
||||||
import org.jetbrains.exposed.v1.core.Transaction
|
import org.jetbrains.exposed.v1.core.Transaction
|
||||||
|
import org.jetbrains.exposed.v1.core.and
|
||||||
|
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
|
import org.jetbrains.exposed.v1.core.inList
|
||||||
|
import org.jetbrains.exposed.v1.core.notInList
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import kotlin.time.Clock
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
object ProjectHandler {
|
object ProjectHandler {
|
||||||
@@ -40,23 +59,49 @@ object ProjectHandler {
|
|||||||
return when {
|
return when {
|
||||||
isEditableBy(user) -> this
|
isEditableBy(user) -> this
|
||||||
isPublic && !forEditing -> this
|
isPublic && !forEditing -> this
|
||||||
else -> throw RouteError("Invalid project ID.", HttpStatusCode.NotFound)
|
else -> throw RouteError("Invalid ID.", HttpStatusCode.NotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context(trns: Transaction)
|
||||||
|
fun Benchmark.isAccessible(user: User, forEditing: Boolean): Pair<Project, Benchmark> {
|
||||||
|
return project.isAccessible(user, forEditing) to this
|
||||||
|
}
|
||||||
|
|
||||||
fun Transaction.accessibleProject(user: User, id: Uuid, forEditing: Boolean): Project {
|
fun Transaction.accessibleProject(user: User, id: Uuid, forEditing: Boolean): Project {
|
||||||
val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound)
|
val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound)
|
||||||
return project.isAccessible(user, forEditing)
|
return project.isAccessible(user, forEditing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Transaction.accessibleProjectCSE(user: User, id: Uuid, forEditing: Boolean): Project {
|
||||||
|
val project = Project.findById(id) ?: throw RouteError.CSERouteError("Invalid project ID.", CloseReasons.NOT_FOUND)
|
||||||
|
return project.isAccessible(user, forEditing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Transaction.accessibleBenchmark(user: User, id: Uuid, forEditing: Boolean): Pair<Project, Benchmark> {
|
||||||
|
val bench = Benchmark.findById(id) ?: throw RouteError("Invalid benchmark ID.", HttpStatusCode.NotFound)
|
||||||
|
return bench.isAccessible(user, forEditing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Transaction.checkMigration(user: User) {
|
||||||
|
if(user.projectVersion < Projects.CURRENT_VERSION)
|
||||||
|
throw RouteError("You need to migrate your projects first.", HttpStatusCode.Conflict)
|
||||||
|
}
|
||||||
|
|
||||||
context(trns: Transaction)
|
context(trns: Transaction)
|
||||||
private fun Project.toResponse(user: User) = ProjectResponse(
|
private fun Project.toResponse(user: User) = ProjectResponse(
|
||||||
id.value, name, NamedID(owner.username, owner.id.value), isPublic, isEditableBy(user),
|
id.value, name, NamedID(owner.username, owner.id.value), isPublic, isEditableBy(user),
|
||||||
labels.orderBy(Versions.label to SortOrder.ASC).map { VersionResponse(it.id.value, it.label, it.color, it.meta) },
|
benchmarks.orderBy(Benchmarks.name to SortOrder.ASC).map { b -> BenchmarkResponse(
|
||||||
entries.orderBy(Entries.timestamp to SortOrder.ASC).map { EntryResponse(it.id.value, it.version.id.value, it.timestamp, it.warmups, it.measurements, it.unit, it.input, it.hardware) })
|
b.id.value, b.name, b.entries.map { e ->
|
||||||
|
EntryResponse(e.id.value, e.benchmarkId.value, e.versionId.value, e.timestamp, e.warmups, e.measurements, e.unit, e.input, e.hardware)
|
||||||
|
}
|
||||||
|
) },
|
||||||
|
versions.orderBy(Versions.label to SortOrder.ASC).map { VersionResponse(it.id.value, it.label, it.color, it.meta) }
|
||||||
|
)
|
||||||
|
|
||||||
fun home(user: User, req: EmptyRequest) = transaction {
|
fun home(user: User, req: EmptyRequest) = transaction {
|
||||||
val own = user.projects.orderBy(Projects.name to SortOrder.ASC).map {
|
val own = user.projects.orderBy(Projects.name to SortOrder.ASC).mapNotNull {
|
||||||
|
if(it.name.startsWith("<migrate")) return@mapNotNull null
|
||||||
HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value))
|
HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +109,23 @@ object ProjectHandler {
|
|||||||
HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value))
|
HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
success(HomeResponse(user.username, user.isAdmin, user.projectLimit, own, publics))
|
val migrations = mutableListOf<HomeResponse.NeededMigration>()
|
||||||
|
var version = user.projectVersion
|
||||||
|
while(version < Projects.CURRENT_VERSION) {
|
||||||
|
migrations += when(version) {
|
||||||
|
0 -> HomeResponse.NeededMigration.Migrate0To1(user.projects.map { p ->
|
||||||
|
HomeResponse.NeededMigration.Migrate0To1.OldProject(p.id.value, p.name, p.versions.map { VersionResponse(it.id.value, it.label, it.color, it.meta) })
|
||||||
|
})
|
||||||
|
else -> throw IllegalArgumentException("No migration starting from $version possible.")
|
||||||
|
}
|
||||||
|
++version
|
||||||
|
}
|
||||||
|
|
||||||
|
success(HomeResponse(user.username, user.isAdmin, user.projectLimit, own, publics, migrations))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createProject(user: User, req: ProjectRequest) = transaction {
|
suspend fun createProject(user: User, req: ProjectRequest) = transaction {
|
||||||
|
checkMigration(user)
|
||||||
if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit))
|
if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit))
|
||||||
throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict)
|
throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict)
|
||||||
|
|
||||||
@@ -88,11 +146,13 @@ object ProjectHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getProject(user: User, req: Uuid) = transaction {
|
fun getProject(user: User, req: Uuid) = transaction {
|
||||||
|
checkMigration(user)
|
||||||
val proj = accessibleProject(user, req, false)
|
val proj = accessibleProject(user, req, false)
|
||||||
success(proj.toResponse(user))
|
success(proj.toResponse(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProject(user: User, req: Pair<Uuid, PartialProjectRequest>) = transaction {
|
fun updateProject(user: User, req: Pair<Uuid, PartialProjectRequest>) = transaction {
|
||||||
|
checkMigration(user)
|
||||||
val proj = accessibleProject(user, req.first, true)
|
val proj = accessibleProject(user, req.first, true)
|
||||||
val changes = req.second
|
val changes = req.second
|
||||||
changes.name?.let { proj.name = it }
|
changes.name?.let { proj.name = it }
|
||||||
@@ -113,6 +173,7 @@ object ProjectHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun deleteProject(user: User, req: Uuid) = transaction {
|
fun deleteProject(user: User, req: Uuid) = transaction {
|
||||||
|
checkMigration(user)
|
||||||
val proj = accessibleProject(user, req, true)
|
val proj = accessibleProject(user, req, true)
|
||||||
proj.delete()
|
proj.delete()
|
||||||
ServerScope.launch {
|
ServerScope.launch {
|
||||||
@@ -124,8 +185,57 @@ object ProjectHandler {
|
|||||||
success(EmptyResponse())
|
success(EmptyResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createVersion(user: User, req: VersionRequest) = transaction {
|
fun createBenchmark(user: User, req: BenchmarkRequest) = transaction {
|
||||||
|
checkMigration(user)
|
||||||
val proj = accessibleProject(user, req.projectId, true)
|
val proj = accessibleProject(user, req.projectId, true)
|
||||||
|
val bench = Benchmark.new {
|
||||||
|
name = req.name
|
||||||
|
projectId = proj.id
|
||||||
|
}
|
||||||
|
|
||||||
|
val res = BenchmarkResponse(bench.id.value, bench.name, bench.entries.map {
|
||||||
|
EntryResponse(it.id.value, it.benchmarkId.value, it.versionId.value, it.timestamp, it.warmups, it.measurements, it.unit, it.input, it.hardware)
|
||||||
|
})
|
||||||
|
|
||||||
|
ServerScope.launch {
|
||||||
|
val projectBus = Buses.projectBus(proj.id.value)
|
||||||
|
if(proj.isPublic) projectBus.sendAll(ProjectEvent.NewBenchmark(res))
|
||||||
|
else projectBus.send(user.id.value, ProjectEvent.NewBenchmark(res))
|
||||||
|
}
|
||||||
|
|
||||||
|
success(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBenchmark(user: User, req: Pair<Uuid, PartialBenchmarkRequest>) = transaction {
|
||||||
|
checkMigration(user)
|
||||||
|
val (proj, bench) = accessibleBenchmark(user, req.first, true)
|
||||||
|
val changes = req.second
|
||||||
|
changes.name?.let { bench.name = it }
|
||||||
|
|
||||||
|
ServerScope.launch {
|
||||||
|
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.BenchmarkChanged(
|
||||||
|
BenchmarkSummary(bench.id.value, bench.name)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
success(EmptyResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteBenchmark(user: User, req: Uuid) = transaction {
|
||||||
|
checkMigration(user)
|
||||||
|
val (proj, bench) = accessibleBenchmark(user, req, true)
|
||||||
|
bench.delete()
|
||||||
|
|
||||||
|
ServerScope.launch {
|
||||||
|
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.BenchmarkDeleted(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
success(EmptyResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createVersion(user: User, req: VersionRequest) = transaction {
|
||||||
|
checkMigration(user)
|
||||||
|
val proj = accessibleProject(user, req.benchmarkId, true)
|
||||||
if(req.color.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest)
|
if(req.color.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest)
|
||||||
val ver = Version.new {
|
val ver = Version.new {
|
||||||
label = req.name
|
label = req.name
|
||||||
@@ -136,13 +246,14 @@ object ProjectHandler {
|
|||||||
|
|
||||||
val res = VersionResponse(ver.id.value, ver.label, ver.color, ver.meta)
|
val res = VersionResponse(ver.id.value, ver.label, ver.color, ver.meta)
|
||||||
ServerScope.launch {
|
ServerScope.launch {
|
||||||
Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewVersion(res))
|
Buses.projectBus(req.benchmarkId).sendAll(ProjectEvent.NewVersion(res))
|
||||||
}
|
}
|
||||||
|
|
||||||
success(res)
|
success(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateVersion(user: User, req: Pair<Uuid, PartialVersionRequest>) = transaction {
|
fun updateVersion(user: User, req: Pair<Uuid, PartialVersionRequest>) = transaction {
|
||||||
|
checkMigration(user)
|
||||||
val ver = Version.findById(req.first) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
|
val ver = Version.findById(req.first) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
|
||||||
ver.project.isAccessible(user, true)
|
ver.project.isAccessible(user, true)
|
||||||
val changes = req.second
|
val changes = req.second
|
||||||
@@ -163,6 +274,7 @@ object ProjectHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun deleteVersion(user: User, req: Uuid) = transaction {
|
fun deleteVersion(user: User, req: Uuid) = transaction {
|
||||||
|
checkMigration(user)
|
||||||
val ver = Version.findById(req) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
|
val ver = Version.findById(req) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
|
||||||
ver.project.isAccessible(user, true)
|
ver.project.isAccessible(user, true)
|
||||||
ver.delete()
|
ver.delete()
|
||||||
@@ -175,14 +287,15 @@ object ProjectHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun createEntry(user: User, req: EntryRequest) = transaction {
|
fun createEntry(user: User, req: EntryRequest) = transaction {
|
||||||
val proj = accessibleProject(user, req.projectId, true)
|
checkMigration(user)
|
||||||
val entry = Entry.new {
|
val (proj, bench) = accessibleBenchmark(user, req.benchmarkId, true)
|
||||||
|
val entry = EntryV2.new {
|
||||||
version = when(val l = Version.findById(req.version)) {
|
version = when(val l = Version.findById(req.version)) {
|
||||||
null -> throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
|
null -> throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
|
||||||
is Version if l.projectId.value != proj.id.value -> throw RouteError("Version is attached to a different project.", HttpStatusCode.Conflict)
|
is Version if l.projectId.value != proj.id.value -> throw RouteError("Version is attached to a different project.", HttpStatusCode.Conflict)
|
||||||
else -> l
|
else -> l
|
||||||
}
|
}
|
||||||
project = proj
|
benchmarkId = bench.id
|
||||||
measurements = req.measurements
|
measurements = req.measurements
|
||||||
timestamp = req.timestamp
|
timestamp = req.timestamp
|
||||||
warmups = req.warmups
|
warmups = req.warmups
|
||||||
@@ -191,7 +304,8 @@ object ProjectHandler {
|
|||||||
hardware = req.hardware
|
hardware = req.hardware
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = EntryResponse(entry.id.value, entry.version.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit, entry.input, entry.hardware)
|
val response = EntryResponse(entry.id.value, entry.benchmarkId.value,
|
||||||
|
entry.version.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit, entry.input, entry.hardware)
|
||||||
ServerScope.launch {
|
ServerScope.launch {
|
||||||
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response))
|
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response))
|
||||||
}
|
}
|
||||||
@@ -199,11 +313,111 @@ object ProjectHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun deleteEntry(user: User, req: Uuid) = transaction {
|
fun deleteEntry(user: User, req: Uuid) = transaction {
|
||||||
val entry = Entry.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
|
checkMigration(user)
|
||||||
entry.project.isAccessible(user, true)
|
val entry = EntryV2.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
|
||||||
|
val (proj, bench) = entry.benchmark.isAccessible(user, true)
|
||||||
entry.delete()
|
entry.delete()
|
||||||
ServerScope.launch {
|
ServerScope.launch {
|
||||||
Buses.projectBus(entry.project.id.value).sendAll(ProjectEvent.EntryDeleted(req))
|
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.EntryDeleted(req, bench.id.value))
|
||||||
|
}
|
||||||
|
success(EmptyResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun migrate01(user: User, req: Migrate0To1Request): Pair<HttpStatusCode, HomeResponse> {
|
||||||
|
val givenProjectIds = req.conversions.flatMap { it.oldProjectsAsBenchmarks }.toSet()
|
||||||
|
val totalProjectCount = req.conversions.sumOf { it.oldProjectsAsBenchmarks.size }
|
||||||
|
if(totalProjectCount != givenProjectIds.size)
|
||||||
|
throw RouteError("Each (legacy) project should only be bound to a single (migrated) project", HttpStatusCode.BadRequest)
|
||||||
|
|
||||||
|
val userProjects = transaction { user.projects.toList() }.map { it.id.value }.toSet()
|
||||||
|
if(givenProjectIds.any { it !in userProjects })
|
||||||
|
throw RouteError("Only a user's own projects should be migrated.", HttpStatusCode.Conflict)
|
||||||
|
|
||||||
|
transaction {
|
||||||
|
user.projects.forEach { it.name = "${it.name}#legacy" }
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction {
|
||||||
|
req.conversions.forEach { (newName, isPublic, olds) ->
|
||||||
|
val proj = Project.new {
|
||||||
|
ownerId = user.id
|
||||||
|
name = newName
|
||||||
|
this.isPublic = isPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
val versionMap = mutableMapOf<Uuid, EntityID<Uuid>>()
|
||||||
|
val duplicateMap = mutableMapOf<String, EntityID<Uuid>>()
|
||||||
|
Version.find { Versions.projectId inList olds }.forEach { oldVersion ->
|
||||||
|
val targetId = duplicateMap.getOrPut(oldVersion.label) {
|
||||||
|
oldVersion.projectId = proj.id
|
||||||
|
oldVersion.id
|
||||||
|
}
|
||||||
|
versionMap[oldVersion.id.value] = targetId
|
||||||
|
}
|
||||||
|
|
||||||
|
olds.forEach { oldId ->
|
||||||
|
val oldProj = accessibleProject(user, oldId, true)
|
||||||
|
val benchmark = Benchmark.new {
|
||||||
|
projectId = proj.id
|
||||||
|
name = oldProj.name.removeSuffix("#legacy")
|
||||||
|
}
|
||||||
|
|
||||||
|
Entry.find { Entries.projectId eq oldId }.forEach {
|
||||||
|
EntryV2.new {
|
||||||
|
benchmarkId = benchmark.id
|
||||||
|
versionId = versionMap[it.versionId.value]!!
|
||||||
|
timestamp = it.timestamp
|
||||||
|
warmups = it.warmups
|
||||||
|
measurements = it.measurements
|
||||||
|
unit = it.unit
|
||||||
|
input = it.input
|
||||||
|
hardware = it.hardware
|
||||||
|
}
|
||||||
|
it.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Versions.deleteWhere {
|
||||||
|
(Versions.projectId inList olds) and (Versions.id notInList versionMap.values.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
Projects.deleteWhere {
|
||||||
|
Projects.id inList olds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.projectVersion = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return home(user, EmptyRequest())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun monitorSetup(id: Uuid, user: User) = Buses.monitorBus(id).also {
|
||||||
|
try {
|
||||||
|
if(!it.isRunning()) it.backlog.reset()
|
||||||
|
it.start(ServerMonitorEvent.ApplicationStart(Clock.System.now()))
|
||||||
|
}
|
||||||
|
catch(e: IllegalStateException) {
|
||||||
|
throw RouteError.CSERouteError("Monitor may already be running. Please refrain from starting a second trace.", CloseReasons.CONFLICT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun monitorTeardown(
|
||||||
|
id: Uuid, user: User,
|
||||||
|
bus: SSEBus.MonitorSSEBus<ServerMonitorEvent, ServerMonitorEvent.ApplicationStart, ServerMonitorEvent.Message,
|
||||||
|
ServerMonitorEvent.ApplicationEnd, ServerMonitorEvent.Backlog>
|
||||||
|
) {
|
||||||
|
bus.end(ServerMonitorEvent.ApplicationEnd(Clock.System.now(), -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMonitor(user: User, req: Uuid) = transaction {
|
||||||
|
checkMigration(user)
|
||||||
|
accessibleProject(user, req, true)
|
||||||
|
val bus = Buses.monitorBus(req)
|
||||||
|
if(bus.isRunning()) throw RouteError("Monitor is still running", HttpStatusCode.Conflict)
|
||||||
|
bus.backlog.reset()
|
||||||
|
ServerScope.launch {
|
||||||
|
bus.bus.sendAll(ServerMonitorEvent.Cleared)
|
||||||
}
|
}
|
||||||
success(EmptyResponse())
|
success(EmptyResponse())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.jaytux.phoebench.server.handlers
|
package com.jaytux.phoebench.server.handlers
|
||||||
|
|
||||||
import com.jaytux.phoebench.common.Auth
|
import com.jaytux.phoebench.common.Auth
|
||||||
|
import com.jaytux.phoebench.common.CloseReasons
|
||||||
import com.jaytux.phoebench.common.ErrorResponse
|
import com.jaytux.phoebench.common.ErrorResponse
|
||||||
import com.jaytux.phoebench.server.db.User
|
import com.jaytux.phoebench.server.db.User
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
@@ -13,6 +14,7 @@ import io.ktor.server.response.respondText
|
|||||||
import io.ktor.server.routing.RoutingCall
|
import io.ktor.server.routing.RoutingCall
|
||||||
import io.ktor.server.routing.RoutingContext
|
import io.ktor.server.routing.RoutingContext
|
||||||
import io.ktor.util.logging.KtorSimpleLogger
|
import io.ktor.util.logging.KtorSimpleLogger
|
||||||
|
import io.ktor.websocket.CloseReason
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
@@ -75,4 +77,9 @@ open class RouteError(message: String, val status: HttpStatusCode = HttpStatusCo
|
|||||||
fun unauthorized(message: String): Nothing =
|
fun unauthorized(message: String): Nothing =
|
||||||
throw RouteError(message, HttpStatusCode.Unauthorized)
|
throw RouteError(message, HttpStatusCode.Unauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class CSERouteError(message: String, val code: Short) : RouteError(message, HttpStatusCode.NotImplemented) {
|
||||||
|
constructor(message: String, code: CloseReason.Codes) : this(message, code.code)
|
||||||
|
constructor(message: String, code: CloseReasons) : this(message, code.code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user