Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8da391b6ce
|
||
|
|
bc9861900e
|
||
|
|
1d10802140
|
||
|
|
62f952ac68
|
||
|
|
c662626bd8
|
||
|
|
e40afb2c3d
|
||
|
|
57586c6600
|
||
|
|
0d3f5ca8ad
|
||
|
|
4fdeafb73b
|
||
|
|
7343300536
|
||
|
|
8dc67adad3
|
||
|
|
8928446444
|
||
|
|
223ec3b886
|
||
|
|
07225ae461
|
||
|
|
462c6da965
|
+6
-1
@@ -45,6 +45,11 @@ bin/
|
||||
.DS_Store
|
||||
|
||||
*.db
|
||||
*.bak*
|
||||
.env
|
||||
.env.*
|
||||
*.sql
|
||||
.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.
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
set -e
|
||||
|
||||
export PHOEBENCH_BUILD_RELEASE='true'
|
||||
|
||||
./gradlew :clients:cli:shadowJar
|
||||
./gradlew :clients:compose:createDistributable
|
||||
./gradlew :clients:compose:wasmJsBrowserProductionWebpack
|
||||
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
import PhoebenchVersion
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.composeMultiplatform) apply false
|
||||
alias(libs.plugins.composeCompiler) apply false
|
||||
@@ -12,4 +14,4 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
version = "1.0.0-dev"
|
||||
version = PhoebenchVersion(1, 1, 0, "")
|
||||
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -8,7 +8,25 @@ plugins {
|
||||
}
|
||||
|
||||
group = "com.jaytux.phoebench"
|
||||
version = rootProject.version.toString()
|
||||
version = PhoebenchVersion(1, 1, 1)
|
||||
|
||||
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 generateVersion = tasks.register<Task>("serverVersion") {
|
||||
doFirst {
|
||||
val outFile = versionDirectory.get().file("com/jaytux/phoebench/client/cli/Version.kt").asFile
|
||||
outFile.parentFile.mkdirs()
|
||||
outFile.writeText("""
|
||||
package com.jaytux.phoebench.client.cli
|
||||
|
||||
object CliVersion {
|
||||
const val VERSION="$version"
|
||||
}
|
||||
""".trimIndent())
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
@@ -42,6 +60,18 @@ kotlin {
|
||||
freeCompilerArgs.add("-Xcontext-parameters")
|
||||
optIn.add("kotlin.uuid.ExperimentalUuidApi")
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
val main by getting {
|
||||
kotlin {
|
||||
srcDir(versionDirectory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.compileKotlin {
|
||||
dependsOn(generateVersion)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<ShadowJar> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.github.ajalt.clikt.parameters.groups.single
|
||||
import com.github.ajalt.clikt.parameters.options.*
|
||||
import com.github.ajalt.clikt.parameters.types.float
|
||||
import com.github.ajalt.clikt.parameters.types.inputStream
|
||||
import com.jaytux.phoebench.client.cli.CliVersion
|
||||
import com.jaytux.phoebench.common.TimeUnit
|
||||
import io.ktor.util.reflect.*
|
||||
import io.ktor.utils.io.*
|
||||
@@ -23,6 +24,10 @@ object CLI {
|
||||
help = "Disable all interactive input (makes arguments marked with (*) mandatory)"
|
||||
).flag(default = false)
|
||||
|
||||
init {
|
||||
versionOption(CliVersion.VERSION)
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
val sub = currentContext.invokedSubcommand
|
||||
if(sub == null) {
|
||||
@@ -96,10 +101,11 @@ object CLI {
|
||||
override fun nestedCommands(): Array<CliktCommand> = fromReflection()
|
||||
|
||||
sealed interface IProjectIdentification
|
||||
sealed interface ILabelIdentification
|
||||
sealed interface IVersionIdentification
|
||||
sealed interface IBenchmarkIdentification
|
||||
data class ProjectName(val user: String, val project: String) : IProjectIdentification
|
||||
data class VersionName(val name: String): ILabelIdentification
|
||||
data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification
|
||||
data class Name(val name: String): IVersionIdentification, IBenchmarkIdentification
|
||||
data class ID(val id: Uuid) : IProjectIdentification, IVersionIdentification, IBenchmarkIdentification
|
||||
|
||||
sealed interface IData<T> {
|
||||
fun toList(): List<T>
|
||||
@@ -179,12 +185,24 @@ object CLI {
|
||||
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")
|
||||
class AddData : CliktCommand(name = "add-data") {
|
||||
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", 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()
|
||||
val warmup by mutuallyExclusiveOptions<IData<Float>>(
|
||||
option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
|
||||
@@ -205,7 +223,14 @@ object CLI {
|
||||
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")
|
||||
|
||||
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 Version : CliktCommand(name = "version") {
|
||||
override fun run() {
|
||||
echo("phoebench-cli version ${CliVersion.VERSION}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +80,10 @@ object Client {
|
||||
val res = route.call(client, body)
|
||||
res
|
||||
} catch (e: CancellationException) {
|
||||
println("Coroutine calling ${route.verb} ${route.pattern} was cancelled.")
|
||||
ErrorResponse("Coroutine calling ${route.verb} ${route.pattern} was cancelled.").error()
|
||||
} catch (e: Exception) {
|
||||
// println("Call to ${route.pattern} ran into an exception")
|
||||
println("Call to ${route.pattern} ran into an exception: ${e.message} (${e::class.simpleName})")
|
||||
ErrorResponse(e.message ?: "Unknown error.").error()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.jaytux.phoebench.clients.cli
|
||||
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
Client.forceGloballyInitialized()
|
||||
CLI.main(args)
|
||||
Client.shutdown()
|
||||
exitProcess(0) // because D-Bus threads...
|
||||
}
|
||||
@@ -1,14 +1,63 @@
|
||||
package com.jaytux.phoebench.clients.cli
|
||||
|
||||
import com.github.javakeyring.BackendNotSupportedException
|
||||
import com.github.javakeyring.Keyring
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
import java.util.prefs.Preferences
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object PersistentStorage {
|
||||
private val json = Json
|
||||
const val SERVICE = "com.jaytux.phoebench.cli"
|
||||
private var _backend: IBackend = KeyringBackend
|
||||
|
||||
interface IBackend {
|
||||
fun read(key: String): String?
|
||||
fun write(key: String, value: String)
|
||||
fun erase(key: String)
|
||||
}
|
||||
|
||||
private object KeyringBackend : IBackend {
|
||||
override fun read(key: String): String? = try {
|
||||
Keyring.create().use { it.getPassword(SERVICE, key) }
|
||||
} catch(e: Exception) {
|
||||
_backend = PreferencesBackend
|
||||
_backend.read(key)
|
||||
}
|
||||
|
||||
override fun write(key: String, value: String) {
|
||||
try {
|
||||
Keyring.create().use { it.setPassword(SERVICE, key, value) }
|
||||
} catch(e: Exception) {
|
||||
_backend = PreferencesBackend
|
||||
_backend.write(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun erase(key: String) {
|
||||
try {
|
||||
Keyring.create().use { it.deletePassword(SERVICE, key) }
|
||||
} catch (e: Exception) {
|
||||
_backend = PreferencesBackend
|
||||
_backend.erase(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object PreferencesBackend : IBackend {
|
||||
private val prefs = Preferences.userNodeForPackage(this::class.java)
|
||||
override fun read(key: String): String? = prefs.get(key, null)
|
||||
|
||||
override fun write(key: String, value: String) {
|
||||
prefs.put(key, value)
|
||||
}
|
||||
|
||||
override fun erase(key: String) {
|
||||
prefs.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
class StoredProperty<T>(
|
||||
private val _key: String,
|
||||
@@ -20,21 +69,15 @@ object PersistentStorage {
|
||||
)
|
||||
|
||||
fun load(): T? = runCatching {
|
||||
Keyring.create().use {
|
||||
it.getPassword(SERVICE, _key)
|
||||
}
|
||||
_backend.read(_key)
|
||||
}.getOrNull()?.let { _fromString(it) }
|
||||
|
||||
fun save(value: T) = runCatching {
|
||||
val keyring = Keyring.create().use {
|
||||
it.setPassword(SERVICE, _key, _toString(value))
|
||||
}
|
||||
_backend.write(_key, _toString(value))
|
||||
}.onFailure { println("Failed to write to OS keyring: ${it.message}") }.ignore()
|
||||
|
||||
fun erase() = runCatching {
|
||||
val keyring = Keyring.create().use {
|
||||
it.deletePassword(SERVICE, _key)
|
||||
}
|
||||
_backend.erase(_key)
|
||||
}.ignore()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.jaytux.phoebench.clients.cli
|
||||
|
||||
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.EmptyRequest
|
||||
import com.jaytux.phoebench.common.EntryRequest
|
||||
@@ -29,6 +30,7 @@ object ProjectHandlers {
|
||||
Client.callRoute(Routes.home, EmptyRequest())
|
||||
}.fold({ err ->
|
||||
System.err.println("Could not get projects list: ${err.msg}")
|
||||
exitProcess(-1)
|
||||
}) {
|
||||
System.err.println("Logged in as ${it.username}")
|
||||
(it.ownProjects + it.publicProjects).toSet().sortedBy { p -> p.name }.forEach { project ->
|
||||
@@ -65,18 +67,22 @@ object ProjectHandlers {
|
||||
}
|
||||
}.fold({
|
||||
System.err.println("Failed to get project details: ${it.msg}")
|
||||
exitProcess(-1)
|
||||
}) {
|
||||
println("Project ${it.owner.name}/${it.name} [${it.id}]:")
|
||||
println("${it.usedLabels.size} labels:")
|
||||
it.usedLabels.forEach { l ->
|
||||
println(" - [${l.id}] ${l.name} (with color ${l.color})")
|
||||
println("${it.versions.size} versions:")
|
||||
it.versions.forEach { v ->
|
||||
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:")
|
||||
it.entries.forEach { e ->
|
||||
println(" - [${e.id}] Entry labeled ${map[e.labelId]?.name ?: "<invalid label>"} at ${e.timestamp.fmt()} " +
|
||||
"(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})")
|
||||
println("\n${it.benchmarks.size} benchmarks:")
|
||||
it.benchmarks.forEach { b ->
|
||||
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})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +93,7 @@ object ProjectHandlers {
|
||||
Client.callRoute(Routes.Project.new, ProjectRequest(useName, isPublic))
|
||||
}.fold({
|
||||
System.err.println("Failed to create project '$useName': ${it.msg}")
|
||||
exitProcess(-1)
|
||||
}) {
|
||||
println("Project ${it.owner.name}/${it.name} [${it.id}] created.")
|
||||
}
|
||||
@@ -94,7 +101,7 @@ object ProjectHandlers {
|
||||
|
||||
fun newVersion(name: String?, color: String?, project: CLI.Commands.Project.IProjectIdentification?, version: String?) {
|
||||
val id = ensureProjectIdentification(project)
|
||||
val useName = name.maybePrompt("label name") { it }
|
||||
val useName = name.maybePrompt("version name") { it }
|
||||
val useColor = color.maybePrompt("color") {
|
||||
val check = it.length == 7 && it[0] == '#' && it.substring(1, it.length).all { c -> c.isDigit() || c in "ABCDEF" }
|
||||
if(check) it
|
||||
@@ -110,9 +117,26 @@ object ProjectHandlers {
|
||||
Client.callRoute(Routes.Version.new, VersionRequest(useName, useColor, useVersion, projectId))
|
||||
}
|
||||
}.fold({
|
||||
System.err.println("Could not create label: ${it.msg}")
|
||||
System.err.println("Could not create version: ${it.msg}")
|
||||
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(
|
||||
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>?,
|
||||
measurement: CLI.Commands.Project.IData<Float>?,
|
||||
unit: TimeUnit?, input: String?, hardware: String?
|
||||
) {
|
||||
val projectId = ensureProjectIdentification(project)
|
||||
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 measureData = measurement.ensure("measurements").toList()
|
||||
@@ -160,19 +188,30 @@ object ProjectHandlers {
|
||||
|
||||
tryAuthenticated {
|
||||
projectId.toId().bind { pId ->
|
||||
when(versionId) {
|
||||
is CLI.Commands.Project.ID -> versionId.id.value()
|
||||
is CLI.Commands.Project.VersionName -> Client.callRoute(Routes.Project.get, pId).bind {
|
||||
it.usedLabels.firstOrNull { l -> l.name == versionId.name }?.id?.value()
|
||||
?: ErrorResponse("Label ${versionId.name} does not exist in this project.").error()
|
||||
Client.callRoute(Routes.Project.get, pId).bind { p ->
|
||||
when (versionId) {
|
||||
is CLI.Commands.Project.ID -> versionId.id.value()
|
||||
is CLI.Commands.Project.Name -> p.versions.firstOrNull { l -> l.name == versionId.name }?.id?.value()
|
||||
?: 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(
|
||||
vId, Clock.System.now(), pId,
|
||||
vId, Clock.System.now(), bId,
|
||||
warmupData, measureData, timeUnit, useInput, useHardware
|
||||
))
|
||||
}
|
||||
}.fold({
|
||||
System.err.println("Failed to add data: ${it.msg}")
|
||||
exitProcess(-1)
|
||||
}) {
|
||||
println("Data added successfully.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,27 @@ plugins {
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.serialization)
|
||||
alias(libs.plugins.buildconfig)
|
||||
}
|
||||
|
||||
version = PhoebenchVersion(1, 1, 2)
|
||||
|
||||
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 generateVersion = tasks.register<Task>("clientVersion") {
|
||||
doFirst {
|
||||
val outFile = versionDirectory.get().file("com/jaytux/phoebench/client/Version.kt").asFile
|
||||
outFile.parentFile.mkdirs()
|
||||
outFile.writeText("""
|
||||
package com.jaytux.phoebench.client
|
||||
|
||||
object ClientVersion {
|
||||
const val VERSION="$version"
|
||||
}
|
||||
""".trimIndent())
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -86,6 +107,20 @@ kotlin {
|
||||
wasmJsMain.dependencies {
|
||||
implementation(libs.ktor.client.js)
|
||||
}
|
||||
|
||||
commonMain {
|
||||
kotlin {
|
||||
srcDir(versionDirectory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targets.all {
|
||||
compilations.all {
|
||||
compileTaskProvider.configure {
|
||||
dependsOn(generateVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +131,7 @@ compose.desktop {
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "com.jaytux.phoebench.clients"
|
||||
packageVersion = rootProject.version.toString().split('-')[0]
|
||||
packageVersion = (version as PhoebenchVersion).safeString()
|
||||
|
||||
jvmArgs += listOf("--enable-native-access=ALL-UNNAMED")
|
||||
includeAllModules = true
|
||||
@@ -105,4 +140,42 @@ 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 {
|
||||
generateAtSync = false
|
||||
useKotlinOutput { internalVisibility = false }
|
||||
|
||||
println("Source sets: ${kotlin.sourceSets.toList().map { it.name }}")
|
||||
|
||||
sourceSets.named("desktopMain") {
|
||||
println("Generating 'desktopMain'")
|
||||
className("PersistenceConstants")
|
||||
packageName("com.jaytux.phoebench.clients")
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ package com.jaytux.phoebench.clients
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jaytux.phoebench.clients.ui.RootView
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTransferData
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.serializer
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
interface IStore {
|
||||
@@ -20,4 +25,17 @@ expect fun persistentStore(): IStore
|
||||
|
||||
expect fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient
|
||||
|
||||
expect suspend fun String.toClipEntry(): ClipEntry
|
||||
expect suspend fun String.toClipEntry(): ClipEntry
|
||||
|
||||
expect fun format(f: Float): String
|
||||
|
||||
@Composable
|
||||
expect fun ServerSelect(server: String, onUpdate: (String) -> Unit)
|
||||
|
||||
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?
|
||||
@@ -19,6 +19,7 @@ import kotlinx.datetime.format.Padding
|
||||
import kotlinx.datetime.format.char
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
@@ -77,17 +78,70 @@ fun Pair<Instant, Instant>.fmtRange() = "Between ${first.fmt()} and ${second.fmt
|
||||
|
||||
infix fun Instant.inRange(range: Pair<Instant, Instant>) = range.first <= this && this <= range.second
|
||||
|
||||
fun Float.fmt(): String {
|
||||
val integer = toInt()
|
||||
val decimals = this - integer
|
||||
val decInt = (decimals * 1000).roundToInt().toFloat() / 1000f
|
||||
return (integer + decInt).toString()
|
||||
}
|
||||
|
||||
inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T) -> X): List<T> {
|
||||
val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy)
|
||||
if(insertionPoint >= 0) return this
|
||||
val index = -insertionPoint - 1
|
||||
|
||||
return toMutableList().apply { add(index, elem) }
|
||||
}
|
||||
|
||||
inline fun <T, X : Comparable<X>> List<T>.insortIdx(elem: T, crossinline sortBy: (T) -> X): Pair<List<T>, Int?> {
|
||||
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)
|
||||
|
||||
fun <T1, T2, T3> List<Triple<T1, T2, T3>>.unzip(): Triple<List<T1>, List<T2>, List<T3>> {
|
||||
val l1 = ArrayList<T1>(size)
|
||||
val l2 = ArrayList<T2>(size)
|
||||
val l3 = ArrayList<T3>(size)
|
||||
|
||||
for((t1, t2, t3) in this) {
|
||||
l1.add(t1)
|
||||
l2.add(t2)
|
||||
l3.add(t3)
|
||||
}
|
||||
|
||||
return l1 to l2 app l3
|
||||
}
|
||||
|
||||
data class Tuple4<T1, T2, T3, T4>(val t1: T1, val t2: T2, val t3: T3, val t4: T4)
|
||||
infix fun <T1, T2, T3, T4> Triple<T1, T2, T3>.app(t4: T4) = Tuple4(first, second, third, t4)
|
||||
|
||||
fun <T1, T2, T3, T4> List<Tuple4<T1, T2, T3, T4>>.unzip(): Tuple4<List<T1>, List<T2>, List<T3>, List<T4>> {
|
||||
val l1 = ArrayList<T1>(size)
|
||||
val l2 = ArrayList<T2>(size)
|
||||
val l3 = ArrayList<T3>(size)
|
||||
val l4 = ArrayList<T4>(size)
|
||||
|
||||
for((t1, t2, t3, t4) in this) {
|
||||
l1.add(t1)
|
||||
l2.add(t2)
|
||||
l3.add(t3)
|
||||
l4.add(t4)
|
||||
}
|
||||
|
||||
return l1 to l2 app l3 app l4
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import com.jaytux.phoebench.common.Either
|
||||
import com.jaytux.phoebench.common.HomeEvent
|
||||
import com.jaytux.phoebench.common.HomeResponse
|
||||
import com.jaytux.phoebench.common.InviteListResponse
|
||||
import com.jaytux.phoebench.common.Migrate0To1Request
|
||||
import com.jaytux.phoebench.common.Routes
|
||||
import com.jaytux.phoebench.common.UserListResponse
|
||||
import com.jaytux.phoebench.common.value
|
||||
@@ -40,11 +41,14 @@ class HomeVM(
|
||||
private val _users = mutableStateOf(listOf<UserListResponse.UserData>())
|
||||
private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>())
|
||||
|
||||
private val _stepsNeeded = mutableStateOf(listOf<HomeResponse.NeededMigration>())
|
||||
|
||||
val username = _username.immutable()
|
||||
val isAdmin = _isAdmin.immutable()
|
||||
val projectLimit = _projectLimit.immutable()
|
||||
val ownProjects = _ownProjects.immutable()
|
||||
val publicProjects = _publicProjects.immutable()
|
||||
val stepsNeeded = _stepsNeeded.immutable()
|
||||
|
||||
val invites = _invites.immutable()
|
||||
val users = _users.immutable()
|
||||
@@ -81,17 +85,22 @@ class HomeVM(
|
||||
withScope {
|
||||
resetAdmin()
|
||||
_repo.getHome().snackOr {
|
||||
println("Received home response $it")
|
||||
_username.value = it.username
|
||||
_isAdmin.value = it.isAdmin
|
||||
_projectLimit.value = it.projectLimit
|
||||
_ownProjects.value = it.ownProjects.sortedBy { p -> p.name }
|
||||
_publicProjects.value = it.publicProjects.sortedBy { p -> p.name }
|
||||
refreshAdmin()
|
||||
refreshFrom(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshFrom(it: HomeResponse) {
|
||||
println("Received home response $it")
|
||||
_username.value = it.username
|
||||
_isAdmin.value = it.isAdmin
|
||||
_projectLimit.value = it.projectLimit
|
||||
_ownProjects.value = it.ownProjects.sortedBy { p -> p.name }
|
||||
_publicProjects.value = it.publicProjects.sortedBy { p -> p.name }
|
||||
_stepsNeeded.value = it.neededMigrations
|
||||
refreshAdmin()
|
||||
}
|
||||
|
||||
fun refreshAdmin() {
|
||||
if(_isAdmin.value) {
|
||||
_adminJob = withScope {
|
||||
@@ -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.InviteRequest
|
||||
import com.jaytux.phoebench.common.LogoutRequest
|
||||
import com.jaytux.phoebench.common.Migrate0To1Request
|
||||
import com.jaytux.phoebench.common.ProjectRequest
|
||||
import com.jaytux.phoebench.common.Routes
|
||||
import com.jaytux.phoebench.common.UserListResponse
|
||||
@@ -31,6 +32,8 @@ interface IHomeRepo {
|
||||
|
||||
suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit>
|
||||
|
||||
suspend fun migrate0To1(conversions: List<Migrate0To1Request.MigrateProject>): Either<ErrorResponse, HomeResponse>
|
||||
|
||||
companion object {
|
||||
class Default(private val _client: Client) : IHomeRepo {
|
||||
override suspend fun getHome(): Either<ErrorResponse, HomeResponse> =
|
||||
@@ -62,6 +65,9 @@ interface IHomeRepo {
|
||||
|
||||
override suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> =
|
||||
_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)
|
||||
|
||||
+21
-5
@@ -4,10 +4,13 @@ import androidx.compose.ui.graphics.Color
|
||||
import com.jaytux.phoebench.clients.Client
|
||||
import com.jaytux.phoebench.clients.hexString
|
||||
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.EntryRequest
|
||||
import com.jaytux.phoebench.common.EntryResponse
|
||||
import com.jaytux.phoebench.common.ErrorResponse
|
||||
import com.jaytux.phoebench.common.PartialBenchmarkRequest
|
||||
import com.jaytux.phoebench.common.VersionRequest
|
||||
import com.jaytux.phoebench.common.VersionResponse
|
||||
import com.jaytux.phoebench.common.PartialProjectRequest
|
||||
@@ -23,11 +26,15 @@ interface IProjectRepo {
|
||||
suspend fun update(name: String? = null, isPublic: Boolean? = null): 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 updateVersion(id: Uuid, name: String? = null, color: Color? = null, meta: String? = null): Either<ErrorResponse, Unit>
|
||||
suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit>
|
||||
suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit>
|
||||
|
||||
suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
|
||||
suspend fun newEntry(benchmark: Uuid, label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
|
||||
unit: TimeUnit, input: String, hardware: String): Either<ErrorResponse, EntryResponse>
|
||||
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
|
||||
|
||||
@@ -42,19 +49,28 @@ interface IProjectRepo {
|
||||
override suspend fun delete(): Either<ErrorResponse, Unit> =
|
||||
_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> =
|
||||
_client.callRoute(Routes.Version.new, VersionRequest(name, color.hexString(), meta, _projectId))
|
||||
|
||||
override suspend fun updateVersion(id: Uuid, name: String?, color: Color?, meta: String?): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Version.update, id to PartialVersionRequest(name, color?.hexString(), meta)).ignoreValue()
|
||||
|
||||
override suspend fun deleteLabel(id: Uuid): Either<ErrorResponse, Unit> =
|
||||
override suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit> =
|
||||
_client.callRoute(Routes.Version.delete, id).ignoreValue()
|
||||
|
||||
override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>,
|
||||
override suspend fun newEntry(benchmark: Uuid, label: Uuid, timestamp: Instant, warmups: List<Float>,
|
||||
measurements: List<Float>, unit: TimeUnit, input: String, hardware: String
|
||||
): 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> =
|
||||
_client.callRoute(Routes.Entry.delete, id).ignoreValue()
|
||||
|
||||
+102
-26
@@ -11,7 +11,10 @@ import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
|
||||
import com.jaytux.phoebench.clients.hexString
|
||||
import com.jaytux.phoebench.clients.immutable
|
||||
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.common.BenchmarkResponse
|
||||
import com.jaytux.phoebench.common.EntryResponse
|
||||
import com.jaytux.phoebench.common.VersionResponse
|
||||
import com.jaytux.phoebench.common.ProjectEvent
|
||||
@@ -57,9 +60,20 @@ class ProjectVM(
|
||||
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) {
|
||||
companion object {
|
||||
fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>) = Entry(
|
||||
it.id, map[it.labelId] ?: Version.invalid, it.timestamp,
|
||||
inline fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>, addInput: (String) -> Unit, addHardware: (String) -> Unit) = Entry(
|
||||
it.id, map[it.versionId] ?: Version.invalid, it.timestamp,
|
||||
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,7 +83,8 @@ class ProjectVM(
|
||||
private val _public = mutableStateOf(false)
|
||||
private val _editable = mutableStateOf(false)
|
||||
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 _hardware = mutableStateOf(setOf<String>())
|
||||
|
||||
@@ -78,7 +93,8 @@ class ProjectVM(
|
||||
val public = _public.immutable()
|
||||
val editable = _editable.immutable()
|
||||
val versions = _labels.immutable()
|
||||
val entries = _entries.immutable()
|
||||
val benchmarks = _benchmarks.immutable()
|
||||
val currentBenchmark = _currentBenchmark.immutable()
|
||||
val inputs = _inputs.immutable()
|
||||
val hardware = _hardware.immutable()
|
||||
|
||||
@@ -102,14 +118,10 @@ class ProjectVM(
|
||||
_owner.value = it.owner.name
|
||||
_public.value = it.isPublic
|
||||
_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 localHardware = mutableSetOf<String>()
|
||||
_entries.value = it.entries.map { e ->
|
||||
localInputs += e.input
|
||||
localHardware += e.hardware
|
||||
Entry.fromResponse(e, _labels.value)
|
||||
}
|
||||
_benchmarks.value = it.benchmarks.map { b -> Benchmark.fromResponse(b, _labels.value, localInputs::add, localHardware::add) }
|
||||
_inputs.value = localInputs
|
||||
_hardware.value = localHardware
|
||||
}
|
||||
@@ -119,13 +131,15 @@ class ProjectVM(
|
||||
private fun handleProjectEvent(event: ProjectEvent) {
|
||||
when(event) {
|
||||
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.VersionDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id }
|
||||
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 -> {
|
||||
_entries.value = _entries.value.insort(Entry.fromResponse(event.entry, _labels.value), Entry::timeStamp)
|
||||
_inputs.value += event.entry.input
|
||||
_hardware.value += event.entry.hardware
|
||||
_benchmarks.value = _benchmarks.value.replace({ it.id == event.entry.benchmarkId }) { it.add(Entry.fromResponse(event.entry, _labels.value,
|
||||
{ input -> _inputs.value += input }, { hw -> _hardware.value += hw })) }
|
||||
}
|
||||
is ProjectEvent.NewVersion -> _labels.value += (event.label.id to Version.fromResponse(event.label))
|
||||
is ProjectEvent.Updated -> {
|
||||
@@ -133,6 +147,23 @@ class ProjectVM(
|
||||
_owner.value = event.changes.owner.name
|
||||
_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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,33 +205,78 @@ class ProjectVM(
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteLabel(id: Uuid) {
|
||||
fun deleteVersion(id: Uuid) {
|
||||
withScope {
|
||||
_repo.deleteLabel(id).snackOr {
|
||||
_repo.deleteVersion(id).snackOr {
|
||||
if(_job == null)
|
||||
_labels.value -= id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun mkEntry(label: Version, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit, input: String, hardware: String) {
|
||||
fun mkBenchmark(name: String) {
|
||||
withScope {
|
||||
_repo.newEntry(label.id, Clock.System.now(), warmups, measurements, unit, input, hardware).snackOr {
|
||||
_repo.newBenchmark(name).snackOr { bench ->
|
||||
if(_job == null) {
|
||||
_entries.value += Entry.fromResponse(it, _labels.value)
|
||||
_inputs.value += input
|
||||
_hardware.value += hardware
|
||||
val (updated, idx) = _benchmarks.value.insortIdx(
|
||||
Benchmark.fromResponse(bench, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw })
|
||||
) { 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 {
|
||||
_repo.deleteEntry(id).snackOr {
|
||||
if(_job == null)
|
||||
_entries.value = _entries.value.filter { it.id != id }
|
||||
val old = _benchmarks.value.find { it.id == id } ?: return@withScope
|
||||
_repo.updateBenchmark(id, name).snackOr {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -1,5 +1,24 @@
|
||||
package com.jaytux.phoebench.clients.theme
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
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 }
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
@@ -28,13 +27,10 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SecondaryScrollableTabRow
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
@@ -49,7 +45,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.backhandler.BackHandler
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -60,7 +55,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.composables.icons.lucide.ChevronLeft
|
||||
import com.composables.icons.lucide.Delete
|
||||
import com.composables.icons.lucide.LogOut
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Pencil
|
||||
@@ -68,8 +62,10 @@ import com.composables.icons.lucide.Plus
|
||||
import com.composables.icons.lucide.ShieldPlus
|
||||
import com.composables.icons.lucide.Trash
|
||||
import com.composables.icons.lucide.X
|
||||
import com.jaytux.phoebench.client.ClientVersion
|
||||
import com.jaytux.phoebench.clients.AuthProvider
|
||||
import com.jaytux.phoebench.clients.Client
|
||||
import com.jaytux.phoebench.clients.ServerSelect
|
||||
import com.jaytux.phoebench.clients.SnackProvider
|
||||
import com.jaytux.phoebench.clients.data.HomeVM
|
||||
import com.jaytux.phoebench.clients.data.mutableStateSetOf
|
||||
@@ -82,13 +78,11 @@ import com.jaytux.phoebench.common.HomeResponse
|
||||
import com.jaytux.phoebench.common.LoginRequest
|
||||
import com.jaytux.phoebench.common.NamedID
|
||||
import com.jaytux.phoebench.common.ProtocolVersion
|
||||
import com.jaytux.phoebench.common.RefreshRequest
|
||||
import com.jaytux.phoebench.common.Routes
|
||||
import com.jaytux.phoebench.common.SignupRequest
|
||||
import com.jaytux.phoebench.common.UserListResponse
|
||||
import com.jaytux.phoebench.common.fold
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Composable
|
||||
@@ -165,6 +159,16 @@ fun RootView(auth: AuthProvider, client: Client) = Surface(tonalElevation = 1.dp
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DefaultServerSelect(server: String, onUpdate: (String) -> Unit) {
|
||||
OutlinedTextField(server, onUpdate, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true)
|
||||
}
|
||||
|
||||
fun constantServerSelect(server: String): (@Composable (server: String, onUpdate: (String) -> Unit) -> Unit) = { _, onUpdate ->
|
||||
onUpdate(server)
|
||||
OutlinedTextField(server, {}, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true, enabled = false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginView(server: String?, error: String?, onSwitchSignup: () -> Unit, onLogin: (server: String, user: String, pass: String) -> Unit) {
|
||||
var serverUrl by remember { mutableStateOf(server ?: "https://") }
|
||||
@@ -176,7 +180,7 @@ fun LoginView(server: String?, error: String?, onSwitchSignup: () -> Unit, onLog
|
||||
Column(Modifier.padding(5.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Log in", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
OutlinedTextField(serverUrl, { serverUrl = it }, Modifier.fillMaxWidth(), label = { Text("Server URL") }, singleLine = true)
|
||||
ServerSelect(serverUrl) { serverUrl = it }
|
||||
|
||||
OutlinedTextField(username, { username = it }, Modifier.fillMaxWidth(), label = { Text("Username") }, singleLine = true)
|
||||
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") }, visualTransformation = PasswordVisualTransformation(), singleLine = true)
|
||||
@@ -208,7 +212,7 @@ fun SignupView(server: String?, error: String?, onSwitchLogin: () -> Unit, onSig
|
||||
Column(Modifier.padding(5.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
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(username, { username = it }, Modifier.fillMaxWidth(), label = { Text("Username") }, singleLine = true)
|
||||
@@ -250,7 +254,7 @@ fun AuthenticatedRoot() {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("PhoeBench", style = MaterialTheme.typography.headlineLarge) },
|
||||
title = { Text("PhoeBench v${ClientVersion.VERSION}", style = MaterialTheme.typography.headlineLarge) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
@@ -316,7 +320,19 @@ fun ConfirmLogoutDialog(onCancel: () -> Unit, onLogout: (everywhere: Boolean) ->
|
||||
@Composable
|
||||
fun HomeView(onSelectProject: (Uuid) -> Unit) {
|
||||
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 username by vm.username
|
||||
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
|
||||
)
|
||||
+697
-260
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,69 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
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.awt.SwingPanel
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
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 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 {
|
||||
Window(onCloseRequest = ::exitApplication, title = "PhoeBench") {
|
||||
App()
|
||||
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") {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-2
@@ -1,19 +1,27 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTransferAction
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTransferData
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTransferable
|
||||
import androidx.compose.ui.draganddrop.awtTransferable
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import com.github.javakeyring.Keyring
|
||||
import com.jaytux.phoebench.clients.ui.DefaultServerSelect
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object JVMStore : IStore {
|
||||
private val json = Json
|
||||
const val SERVICE = "com.jaytux.phoebench"
|
||||
const val SERVICE = PersistenceConstants.SERVICE
|
||||
|
||||
private class StoredProperty<T>(
|
||||
val key: String,
|
||||
@@ -55,4 +63,22 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
actual suspend fun String.toClipEntry(): ClipEntry =
|
||||
ClipEntry(StringSelection(this))
|
||||
ClipEntry(StringSelection(this))
|
||||
|
||||
actual fun format(f: Float): String = String.format("%.03f", f).let { it.substring(0, minOf(it.length, 5)) }
|
||||
|
||||
@Composable
|
||||
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
|
||||
|
||||
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.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.window
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
suspend fun main() {
|
||||
config = loadConfig()
|
||||
ComposeViewport(document.body!!) {
|
||||
App()
|
||||
val width = LocalWindowInfo.current.containerSize
|
||||
var mode by remember(width) { mutableStateOf(UiMode.fromDp(width.width.dp)) }
|
||||
|
||||
CompositionLocalProvider(GlobalUiMode provides mode) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-1
@@ -1,12 +1,23 @@
|
||||
package com.jaytux.phoebench.clients
|
||||
|
||||
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 com.jaytux.phoebench.clients.ui.constantServerSelect
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.js.*
|
||||
import kotlinx.browser.window
|
||||
import kotlinx.coroutines.await
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
import org.w3c.dom.DataTransfer
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object WasmJsStore : IStore {
|
||||
@@ -46,4 +57,42 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
|
||||
}
|
||||
|
||||
actual suspend fun String.toClipEntry(): ClipEntry =
|
||||
ClipEntry.withPlainText(this)
|
||||
ClipEntry.withPlainText(this)
|
||||
|
||||
@OptIn(ExperimentalWasmJsInterop::class)
|
||||
@JsFun("(num) => num.toFixed(3)")
|
||||
private external fun jsFormat(num: Double): String
|
||||
|
||||
actual fun format(f: Float): String = jsFormat(f.toDouble()).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
|
||||
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) {
|
||||
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)
|
||||
}
|
||||
@@ -11,6 +11,8 @@ val partialsDirectory = layout.buildDirectory.dir("generated/sources/partials")
|
||||
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
|
||||
val requestsDirectory = layout.projectDirectory.dir("src/commonMain/kotlin/com/jaytux/phoebench/common/")
|
||||
|
||||
version = rootProject.version as PhoebenchVersion
|
||||
|
||||
val generatePartials = tasks.register<Exec>("generatePartials") {
|
||||
group = "generation"
|
||||
description = "Generate Partial classes (requests with all-nullable fields)"
|
||||
@@ -37,7 +39,7 @@ val generateVersion = tasks.register<Task>("protocolVersion") {
|
||||
package com.jaytux.phoebench.common
|
||||
|
||||
object ProtocolVersion {
|
||||
const val VERSION="${rootProject.version}"
|
||||
const val VERSION="${version}"
|
||||
}
|
||||
""".trimIndent())
|
||||
}
|
||||
|
||||
@@ -42,6 +42,15 @@ sealed class ProjectEvent {
|
||||
@Serializable
|
||||
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
|
||||
data class NewVersion(val label: VersionResponse) : ProjectEvent()
|
||||
|
||||
@@ -55,5 +64,5 @@ sealed class ProjectEvent {
|
||||
data class NewEntry(val entry: EntryResponse) : ProjectEvent()
|
||||
|
||||
@Serializable
|
||||
data class EntryDeleted(val id: Uuid) : ProjectEvent()
|
||||
data class EntryDeleted(val id: Uuid, val benchmarkId: Uuid) : ProjectEvent()
|
||||
}
|
||||
@@ -19,13 +19,16 @@ class EmptyRequest
|
||||
data class RefreshRequest(val refreshToken: Uuid)
|
||||
|
||||
@Serializable @ToPartialize
|
||||
data class ProjectRequest(val name: String, var isPublic: Boolean)
|
||||
data class ProjectRequest(val name: String, val isPublic: Boolean)
|
||||
|
||||
@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
|
||||
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)
|
||||
|
||||
@Serializable
|
||||
@@ -35,4 +38,10 @@ data class LogoutRequest(val refresh: Uuid)
|
||||
data class UserUpdateRequest(val projectLimit: Int? = null, val isAdmin: Boolean? = null)
|
||||
|
||||
@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)
|
||||
|
||||
@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
|
||||
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
|
||||
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
|
||||
data class VersionResponse(val id: Uuid, val name: String, val color: String, val meta: String)
|
||||
|
||||
@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)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -22,6 +22,7 @@ object Routes {
|
||||
|
||||
val handshake = ApiRoute.get<HandshakeResponse>("/", Elevation.UN_AUTH)
|
||||
val home = ApiRoute.get<HomeResponse>("/home", Elevation.AUTH)
|
||||
val migrate0To1 = ApiRoute.post<Migrate0To1Request, HomeResponse>("/migrate/0-1", Elevation.AUTH)
|
||||
|
||||
object Project {
|
||||
val new = ApiRoute.post<ProjectRequest, ProjectResponse>("/project", Elevation.AUTH)
|
||||
@@ -30,6 +31,12 @@ object Routes {
|
||||
val delete = ApiRoute.deleteUuidNoRes("/project", 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 {
|
||||
val new = ApiRoute.post<VersionRequest, VersionResponse>("/version", Elevation.AUTH)
|
||||
val update = ApiRoute.patchUuidNoRes<PartialVersionRequest>("/version", Elevation.AUTH)
|
||||
|
||||
@@ -22,6 +22,7 @@ koala-plot = "0.12.0"
|
||||
kolor-picker = "2.1.0"
|
||||
shadow = "9.3.0"
|
||||
clikt = "5.0.3"
|
||||
buildconfig = "6.0.10"
|
||||
|
||||
[libraries]
|
||||
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
|
||||
@@ -94,4 +95,5 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref =
|
||||
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
|
||||
jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
|
||||
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
|
||||
buildconfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildconfig" }
|
||||
@@ -7,11 +7,30 @@ plugins {
|
||||
}
|
||||
|
||||
group = "com.jaytux.phoebench"
|
||||
version = PhoebenchVersion(1, 1, 2)
|
||||
|
||||
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
|
||||
throw GradleException("Server version must be at least as high as protocol/common version")
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
|
||||
val generateVersion = tasks.register<Task>("serverVersion") {
|
||||
doFirst {
|
||||
val outFile = versionDirectory.get().file("com/jaytux/phoebench/server/Version.kt").asFile
|
||||
outFile.parentFile.mkdirs()
|
||||
outFile.writeText("""
|
||||
package com.jaytux.phoebench.server
|
||||
|
||||
object ServerVersion {
|
||||
const val VERSION="$version"
|
||||
}
|
||||
""".trimIndent())
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.exposed.core)
|
||||
implementation(libs.exposed.dao)
|
||||
@@ -63,6 +82,18 @@ kotlin {
|
||||
freeCompilerArgs.add("-Xcontext-parameters")
|
||||
optIn.add("kotlin.uuid.ExperimentalUuidApi")
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
val main by getting {
|
||||
kotlin {
|
||||
srcDir(versionDirectory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.compileKotlin {
|
||||
dependsOn(generateVersion)
|
||||
}
|
||||
}
|
||||
|
||||
application {
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.jaytux.phoebench.server
|
||||
|
||||
import com.jaytux.phoebench.common.*
|
||||
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.User
|
||||
import com.jaytux.phoebench.server.handlers.*
|
||||
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject
|
||||
@@ -25,35 +27,33 @@ import kotlin.uuid.Uuid
|
||||
|
||||
fun Application.module() {
|
||||
DB.db
|
||||
println("Entering module...")
|
||||
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) {
|
||||
json()
|
||||
}
|
||||
|
||||
install(StatusPages) {
|
||||
status(HttpStatusCode.Forbidden) { call, status ->
|
||||
call.respond(status, ErrorResponse("Access Forbidden: CORS failed."))
|
||||
}
|
||||
status(HttpStatusCode.Unauthorized) { call, status ->
|
||||
call.respond(status, ErrorResponse("Unauthorized/unauthenticated."))
|
||||
}
|
||||
}
|
||||
|
||||
install(SSE) {
|
||||
//
|
||||
}
|
||||
|
||||
val allowLocalhost = environment.config.propertyOrNull("ktor.cors.enableLocalhostOn")?.getString() ?: "0"
|
||||
val safeOrigin = environment.config.propertyOrNull("ktor.cors.browserOrigin")?.getString()
|
||||
install(CORS) {
|
||||
// -- methods --
|
||||
allowMethod(HttpMethod.Options)
|
||||
allowMethod(HttpMethod.Delete)
|
||||
allowMethod(HttpMethod.Patch)
|
||||
allowMethod(HttpMethod.Get)
|
||||
allowMethod(HttpMethod.Post)
|
||||
// -- headers --
|
||||
allowHeader(HttpHeaders.Accept)
|
||||
allowHeader(HttpHeaders.Pragma)
|
||||
allowHeader(HttpHeaders.Authorization)
|
||||
allowHeader(HttpHeaders.ContentType)
|
||||
allowHeader(HttpHeaders.CacheControl)
|
||||
allowHeader(HttpHeaders.LastEventID)
|
||||
// -- exposed headers --
|
||||
exposeHeader(HttpHeaders.ContentType)
|
||||
allowCredentials = true
|
||||
allowNonSimpleContentTypes = true
|
||||
@@ -76,6 +76,17 @@ fun Application.module() {
|
||||
}
|
||||
}
|
||||
|
||||
install(SSE) {}
|
||||
|
||||
install(StatusPages) {
|
||||
status(HttpStatusCode.Forbidden) { call, status ->
|
||||
call.respond(status, ErrorResponse("Access Forbidden: CORS failed."))
|
||||
}
|
||||
status(HttpStatusCode.Unauthorized) { call, status ->
|
||||
call.respond(status, ErrorResponse("Unauthorized/unauthenticated."))
|
||||
}
|
||||
}
|
||||
|
||||
install(AutoHeadResponse)
|
||||
install(CallLogging)
|
||||
|
||||
@@ -120,12 +131,17 @@ fun Application.module() {
|
||||
deleteAdmin(Routes.Auth.User.delete, AuthHandler::deleteUser)
|
||||
|
||||
getAuth(Routes.home, ProjectHandler::home)
|
||||
postAuth(Routes.migrate0To1, ProjectHandler::migrate01)
|
||||
|
||||
postAuth(Routes.Project.new, ProjectHandler::createProject)
|
||||
getAuth(Routes.Project.get, ProjectHandler::getProject)
|
||||
patchAuth(Routes.Project.update, ProjectHandler::updateProject)
|
||||
deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject)
|
||||
|
||||
postAuth(Routes.Benchmark.new, ProjectHandler::createBenchmark)
|
||||
patchAuth(Routes.Benchmark.update, ProjectHandler::updateBenchmark)
|
||||
deleteAuth(Routes.Benchmark.delete, ProjectHandler::deleteBenchmark)
|
||||
|
||||
postAuth(Routes.Version.new, ProjectHandler::createVersion)
|
||||
patchAuth(Routes.Version.update, ProjectHandler::updateVersion)
|
||||
deleteAuth(Routes.Version.delete, ProjectHandler::deleteVersion)
|
||||
|
||||
@@ -21,4 +21,6 @@ fun nowPlusDays(days: Int): Instant = nowPlus(days, DateTimeUnit.DAY)
|
||||
fun nowPlus(time: Int, unit: DateTimeUnit): Instant {
|
||||
val now = Clock.System.now()
|
||||
return now.plus(time, unit, systemTZ)
|
||||
}
|
||||
}
|
||||
|
||||
infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.jaytux.phoebench.server.db
|
||||
|
||||
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.SchemaUtils
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
|
||||
|
||||
object DB {
|
||||
@@ -17,7 +19,7 @@ object DB {
|
||||
transaction {
|
||||
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 {
|
||||
migration.forEach {
|
||||
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
|
||||
|
||||
import org.jetbrains.exposed.v1.core.Transaction
|
||||
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||
import org.jetbrains.exposed.v1.dao.Entity
|
||||
import org.jetbrains.exposed.v1.dao.EntityClass
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
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) {
|
||||
companion object : EntityClass<Uuid, User>(Users)
|
||||
|
||||
@@ -13,6 +28,8 @@ class User(id: EntityID<Uuid>) : Entity<Uuid>(id) {
|
||||
var isAdmin by Users.isAdmin
|
||||
var isOwner by Users.isOwner
|
||||
var projectLimit by Users.projectLimit
|
||||
var benchmarkLimit by Users.benchmarkLimit
|
||||
var projectVersion by Users.projectsVersion
|
||||
|
||||
val projects by Project referrersOn Projects.ownerId
|
||||
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 owner by User referencedOn Projects.ownerId
|
||||
val labels by Version referrersOn Versions.projectId
|
||||
val entries by Entry referrersOn Entries.projectId
|
||||
val versions by Version referrersOn Versions.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) {
|
||||
@@ -71,4 +98,20 @@ class Entry(id: EntityID<Uuid>) : Entity<Uuid>(id) {
|
||||
|
||||
var project by Project referencedOn Entries.projectId
|
||||
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 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.datetime.timestamp
|
||||
import kotlin.time.Clock
|
||||
|
||||
object Metas : IntIdTable() {
|
||||
const val CURRENT_REVISION = 1
|
||||
|
||||
val revision = integer("revision").default(0)
|
||||
}
|
||||
|
||||
object Users : UuidTable() {
|
||||
val username = varchar("username", 255).uniqueIndex()
|
||||
val password = varchar("password", 255)
|
||||
val isAdmin = bool("is_admin").default(false)
|
||||
val isOwner = bool("is_owner").default(false)
|
||||
val projectLimit = integer("project_limit").default(1)
|
||||
val benchmarkLimit = integer("benchmark_limit").default(1)
|
||||
|
||||
val projectsVersion = integer("project_version").default(0)
|
||||
}
|
||||
|
||||
object Invites : UuidTable() {
|
||||
@@ -25,6 +35,8 @@ object RefreshTokens : UuidTable() {
|
||||
}
|
||||
|
||||
object Projects : UuidTable() {
|
||||
const val CURRENT_VERSION = 1
|
||||
|
||||
val ownerId = reference("owner_id", Users, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
|
||||
val name = varchar("name", 255)
|
||||
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() {
|
||||
val label = varchar("label", 255)
|
||||
val color = varchar("color", 7)
|
||||
@@ -50,4 +71,15 @@ object Entries : UuidTable() {
|
||||
val unit = enumeration<TimeUnit>("unit")
|
||||
val input = varchar("input", 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)
|
||||
}
|
||||
@@ -1,22 +1,31 @@
|
||||
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.EmptyRequest
|
||||
import com.jaytux.phoebench.common.EmptyResponse
|
||||
import com.jaytux.phoebench.common.EntryRequest
|
||||
import com.jaytux.phoebench.common.EntryResponse
|
||||
import com.jaytux.phoebench.common.HomeEvent
|
||||
import com.jaytux.phoebench.common.HomeResponse
|
||||
import com.jaytux.phoebench.common.Migrate0To1Request
|
||||
import com.jaytux.phoebench.common.VersionRequest
|
||||
import com.jaytux.phoebench.common.VersionResponse
|
||||
import com.jaytux.phoebench.common.NamedID
|
||||
import com.jaytux.phoebench.common.PartialBenchmarkRequest
|
||||
import com.jaytux.phoebench.common.PartialProjectRequest
|
||||
import com.jaytux.phoebench.common.PartialVersionRequest
|
||||
import com.jaytux.phoebench.common.ProjectEvent
|
||||
import com.jaytux.phoebench.common.ProjectRequest
|
||||
import com.jaytux.phoebench.common.ProjectResponse
|
||||
import com.jaytux.phoebench.server.Buses
|
||||
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.Entry
|
||||
import com.jaytux.phoebench.server.db.EntryV2
|
||||
import com.jaytux.phoebench.server.db.Version
|
||||
import com.jaytux.phoebench.server.db.Versions
|
||||
import com.jaytux.phoebench.server.db.Project
|
||||
@@ -27,7 +36,12 @@ import io.ktor.http.HttpStatusCode
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
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.inList
|
||||
import org.jetbrains.exposed.v1.core.notInList
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@@ -40,23 +54,44 @@ object ProjectHandler {
|
||||
return when {
|
||||
isEditableBy(user) -> 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 {
|
||||
val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound)
|
||||
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)
|
||||
private fun Project.toResponse(user: User) = ProjectResponse(
|
||||
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) },
|
||||
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) })
|
||||
benchmarks.orderBy(Benchmarks.name to SortOrder.ASC).map { b -> BenchmarkResponse(
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -64,10 +99,23 @@ object ProjectHandler {
|
||||
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 {
|
||||
checkMigration(user)
|
||||
if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit))
|
||||
throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict)
|
||||
|
||||
@@ -88,11 +136,13 @@ object ProjectHandler {
|
||||
}
|
||||
|
||||
fun getProject(user: User, req: Uuid) = transaction {
|
||||
checkMigration(user)
|
||||
val proj = accessibleProject(user, req, false)
|
||||
success(proj.toResponse(user))
|
||||
}
|
||||
|
||||
fun updateProject(user: User, req: Pair<Uuid, PartialProjectRequest>) = transaction {
|
||||
checkMigration(user)
|
||||
val proj = accessibleProject(user, req.first, true)
|
||||
val changes = req.second
|
||||
changes.name?.let { proj.name = it }
|
||||
@@ -113,6 +163,7 @@ object ProjectHandler {
|
||||
}
|
||||
|
||||
fun deleteProject(user: User, req: Uuid) = transaction {
|
||||
checkMigration(user)
|
||||
val proj = accessibleProject(user, req, true)
|
||||
proj.delete()
|
||||
ServerScope.launch {
|
||||
@@ -124,8 +175,57 @@ object ProjectHandler {
|
||||
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 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)
|
||||
val ver = Version.new {
|
||||
label = req.name
|
||||
@@ -136,13 +236,14 @@ object ProjectHandler {
|
||||
|
||||
val res = VersionResponse(ver.id.value, ver.label, ver.color, ver.meta)
|
||||
ServerScope.launch {
|
||||
Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewVersion(res))
|
||||
Buses.projectBus(req.benchmarkId).sendAll(ProjectEvent.NewVersion(res))
|
||||
}
|
||||
|
||||
success(res)
|
||||
}
|
||||
|
||||
fun updateVersion(user: User, req: Pair<Uuid, PartialVersionRequest>) = transaction {
|
||||
checkMigration(user)
|
||||
val ver = Version.findById(req.first) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
|
||||
ver.project.isAccessible(user, true)
|
||||
val changes = req.second
|
||||
@@ -163,6 +264,7 @@ object ProjectHandler {
|
||||
}
|
||||
|
||||
fun deleteVersion(user: User, req: Uuid) = transaction {
|
||||
checkMigration(user)
|
||||
val ver = Version.findById(req) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
|
||||
ver.project.isAccessible(user, true)
|
||||
ver.delete()
|
||||
@@ -175,14 +277,15 @@ object ProjectHandler {
|
||||
}
|
||||
|
||||
fun createEntry(user: User, req: EntryRequest) = transaction {
|
||||
val proj = accessibleProject(user, req.projectId, true)
|
||||
val entry = Entry.new {
|
||||
checkMigration(user)
|
||||
val (proj, bench) = accessibleBenchmark(user, req.benchmarkId, true)
|
||||
val entry = EntryV2.new {
|
||||
version = when(val l = Version.findById(req.version)) {
|
||||
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)
|
||||
else -> l
|
||||
}
|
||||
project = proj
|
||||
benchmarkId = bench.id
|
||||
measurements = req.measurements
|
||||
timestamp = req.timestamp
|
||||
warmups = req.warmups
|
||||
@@ -191,7 +294,8 @@ object ProjectHandler {
|
||||
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 {
|
||||
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response))
|
||||
}
|
||||
@@ -199,12 +303,82 @@ object ProjectHandler {
|
||||
}
|
||||
|
||||
fun deleteEntry(user: User, req: Uuid) = transaction {
|
||||
val entry = Entry.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
|
||||
entry.project.isAccessible(user, true)
|
||||
checkMigration(user)
|
||||
val entry = EntryV2.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
|
||||
val (proj, bench) = entry.benchmark.isAccessible(user, true)
|
||||
entry.delete()
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
org.slf4j.simpleLogger.log.io.ktor.server.plugins.cors=TRACE
|
||||
Reference in New Issue
Block a user