21 Commits
Author SHA1 Message Date
jaytux 589649d9f6 Server: patch crashing applications blocks monitor 2026-09-02 15:11:06 +02:00
jaytux ae7724b3c4 UI: re-render when versions change 2026-09-01 20:12:29 +02:00
jaytux ef5ccb9274 UI: auto-scrolling 2026-09-01 16:57:21 +02:00
jaytux 9e010c7873 Server: harden against crashes 2026-09-01 16:31:00 +02:00
jaytux fce1c097d4 Java Preferences sync bug 2026-09-01 16:01:18 +02:00
jaytux b5c4e150a5 UI: removing benchmarks 2026-09-01 15:28:52 +02:00
jaytux 126a3bfff2 Clients: use http->ws, https->wss 2026-09-01 15:17:39 +02:00
jaytux 563ee98d03 ANSI update 2026-09-01 14:58:17 +02:00
jaytux 7f92df1ca5 CLI: fix keyring backend bug 2026-09-01 14:09:37 +02:00
jaytux 768bb0c650 Fix window resize (desktop) 2026-09-01 11:54:56 +02:00
jaytux 85902620da Script monitoring 2026-09-01 11:47:10 +02:00
jaytux 8da391b6ce Web UI: width detection 2026-08-30 21:24:56 +02:00
jaytux bc9861900e UI clients: fix narrow vs wide UI 2026-08-30 21:11:18 +02:00
jaytux 1d10802140 CLI: formatting 2026-08-27 15:23:07 +02:00
jaytux 62f952ac68 CORS headers for SSE 2026-08-27 14:29:27 +02:00
jaytux c662626bd8 Server version bump 2026-08-27 14:11:19 +02:00
jaytux e40afb2c3d Server: CORS support on SSE routes 2026-08-27 14:10:04 +02:00
jaytux 57586c6600 Benchmark editing 2026-08-27 13:52:57 +02:00
jaytux 0d3f5ca8ad Schema update (organising benchmarks within projects) 2026-08-27 13:22:46 +02:00
jaytux 4fdeafb73b Database reorganize, protocol version bump, migrations 2026-08-26 23:13:47 +02:00
jaytux 7343300536 UI: filtering improvements 2026-08-26 11:05:34 +02:00
56 changed files with 2732 additions and 433 deletions
+3
View File
@@ -45,7 +45,10 @@ bin/
.DS_Store
*.db
*.bak*
.env
.env.*
*.sql
.idea/
artifacts/
+4 -2
View File
@@ -2,6 +2,8 @@
set -e
export PHOEBENCH_BUILD_RELEASE='true'
./gradlew :clients:cli:shadowJar
./gradlew :clients:compose:createDistributable
./gradlew :clients:compose:wasmJsBrowserProductionWebpack
@@ -14,8 +16,8 @@ cp clients/cli/build/libs/phoebench-cli.jar artifacts/
cp clients/compose/build/compose/binaries/main/app/com.jaytux.phoebench.clients/phoebench-linux.zip artifacts/
mkdir -p artifacts/wasm
cp clients/compose/build/kotlin-webpack/wasmJs/productionExecutable/* clients/compose/src/wasmJsMain/resources/* artifacts/wasm
(cd artifacts/wasm && zip phoebench-wasm.zip -r ./*)
cp -r clients/compose/build/kotlin-webpack/wasmJs/productionExecutable/* clients/compose/build/processedResources/wasmJs/main/* artifacts/wasm
(cd artifacts/wasm && rm config.json && zip phoebench-wasm.zip -r ./*)
mv artifacts/wasm/phoebench-wasm.zip artifacts/
rm -fr artifacts/wasm
+3 -1
View File
@@ -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"
version = PhoebenchVersion(1, 2, 1, "")
+7
View File
@@ -0,0 +1,7 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
+18
View File
@@ -0,0 +1,18 @@
import org.gradle.api.Project
fun Project.envValue(key: String): String? {
val fromEnv = providers.environmentVariable(key).orNull
if(fromEnv != null) return fromEnv
val envFile = rootProject.file(".env")
if(!envFile.exists()) return null
return envFile.useLines { lines ->
lines.map { it.trim() }.filter { it.isNotBlank() && !it.startsWith('#') && '=' in it }
.map { line ->
val (k, v) = line.split('=', limit = 2)
k.trim() to v.trim().removeSurrounding("\"").removeSurrounding("'")
}
.firstOrNull { it.first == key }?.second
}
}
@@ -0,0 +1,14 @@
data class PhoebenchVersion(val major: Int, val minor: Int, val patch: Int, val note: String = "") {
operator fun compareTo(other: PhoebenchVersion): Int {
// this > other -> return >0
val majorComp = major - other.major
if(majorComp != 0) return majorComp
val minorComp = minor - other.minor
if(minorComp != 0) return minorComp
return patch - other.patch
}
override fun toString(): String = if(note.isNotBlank()) "$major.$minor.$patch-$note" else safeString()
fun safeString() = "$major.$minor.$patch"
}
+31 -2
View File
@@ -5,10 +5,14 @@ plugins {
application
alias(libs.plugins.serialization)
alias(libs.plugins.shadow)
alias(libs.plugins.buildconfig)
}
group = "com.jaytux.phoebench"
version = "1.0.1"
version = PhoebenchVersion(1, 2, 1, "patch0")
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("CLI Client version must be at least as high as protocol/common version")
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val generateVersion = tasks.register<Task>("serverVersion") {
@@ -25,12 +29,15 @@ val generateVersion = tasks.register<Task>("serverVersion") {
}
}
val isDebug = envValue("PHOEBENCH_BUILD_RELEASE") == null
dependencies {
implementation(kotlin("stdlib"))
implementation(libs.clikt)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.websocket)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization)
implementation(project(":common"))
@@ -39,6 +46,7 @@ dependencies {
implementation(libs.slf4j.simple)
implementation(libs.java.keystore)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.process)
}
application {
@@ -72,5 +80,26 @@ kotlin {
}
tasks.withType<ShadowJar> {
archiveFileName = "phoebench-cli.jar"
val archive = if(isDebug) "phoebench-cli-debug.jar" else "phoebench-cli.jar"
println("Using archive '$archive'")
archiveFileName = archive
}
buildConfig {
generateAtSync = false
useKotlinOutput { internalVisibility = false }
println("Source sets: ${kotlin.sourceSets.toList().map { it.name }}")
className("PersistenceConstants")
packageName("com.jaytux.phoebench.clients.cli")
if(isDebug) {
println("Using debug CLI service")
buildConfigField("SERVICE", "com.jaytux.phoebench.cli.debug")
}
else {
println("Using release CLI service")
buildConfigField("SERVICE", "com.jaytux.phoebench.cli")
}
}
@@ -6,16 +6,21 @@ import kotlin.system.exitProcess
import kotlin.uuid.Uuid
object AuthHandlers {
suspend fun checkServer() =
Client.callRoute(Routes.handshake, EmptyRequest()).bind { handshake ->
if(handshake.version != ProtocolVersion.VERSION) {
val server = Client.getServer() ?: "<unknown server ULR>"
Client.onClearServer()
ErrorResponse("Cannot use $server: protocol version mismatch (server: ${handshake.version}, expected: ${ProtocolVersion.VERSION})").error()
}
Unit.value()
}
fun serverPrompt(server: String?): Either<ErrorResponse, Unit> {
val useServer = server.maybePrompt("server") { it }
Client.onSelectServer(useServer)
return runBlocking {
Client.callRoute(Routes.handshake, EmptyRequest()).bind { handshake ->
if (handshake.version != ProtocolVersion.VERSION) {
Client.onClearServer()
ErrorResponse("Mismatched protocol version (server: ${handshake.version}, client: ${ProtocolVersion.VERSION})").error()
} else Unit.value()
}
checkServer().bind { Client.onSelectServer(useServer).value() }
}
}
@@ -1,6 +1,8 @@
package com.jaytux.phoebench.clients.cli
import com.github.ajalt.clikt.core.*
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.multiple
import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions
import com.github.ajalt.clikt.parameters.groups.single
import com.github.ajalt.clikt.parameters.options.*
@@ -101,10 +103,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>
@@ -184,12 +187,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()) },
@@ -210,7 +225,25 @@ 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 Monitor : CliktCommand(name = "monitor") {
val finder by mutuallyExclusiveOptions<Project.IProjectIdentification>(
option("--id", help = "Find a project by UUID.").convert { Project.ID(Uuid.parse(it)) },
option("--name", help = "Find a project by name (formatted [user]/[project])").convert {
val split = it.split('/')
if(split.size != 2) throw IllegalArgumentException("Invalid format (expected [user]/[project])")
Project.ProjectName(split[0], split[1])
}
).single()
val command by argument("command", help = "The command to be run")
val commandArgs by argument("arguments", help = "Arguments to pass to the command").multiple()
override fun run() {
MonitorHandler.monitor(finder, command, commandArgs)
}
}
@@ -1,6 +1,7 @@
package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.CSERoute
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.IClient
@@ -9,15 +10,31 @@ import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.TokenResponse
import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.foldSuspend
import com.jaytux.phoebench.common.value
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.ResponseException
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.websocket.WebSocketException
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.sendSerialized
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.url
import io.ktor.http.HttpMethod
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.serialization.kotlinx.json.json
import io.ktor.util.reflect.TypeInfo
import io.ktor.util.reflect.typeInfo
import io.ktor.utils.io.CancellationException
import io.ktor.websocket.CloseReason
import kotlinx.coroutines.asExecutor
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import java.net.URL
import kotlin.uuid.Uuid
object Client {
@@ -68,6 +85,10 @@ object Client {
}
}
}
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
}
init {
@@ -91,6 +112,19 @@ object Client {
suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> =
callRoute(_client, route, body)
suspend fun <TParams: Any, TEvent: Any> callCSE(
route: CSERoute<TParams, TEvent>, params: TParams,
body: suspend (sender: suspend (TEvent) -> Unit) -> Unit
): Either<ErrorResponse, Unit> {
try {
val client = IClient.Default(_client, _server ?: throw IllegalStateException("No server URL set."))
return route.call(client, params, body)
}
catch(e: CancellationException) {
return ErrorResponse("Event stream disconnected.").error()
}
}
fun onLogin(tokens: TokenResponse) {
_refreshToken = tokens.refresh
_accessToken = tokens.access
@@ -0,0 +1,83 @@
package com.jaytux.phoebench.clients.cli
import com.github.pgreze.process.Redirect
import com.github.pgreze.process.process
import com.jaytux.phoebench.clients.cli.ProjectHandlers.toId
import com.jaytux.phoebench.common.ANSI
import com.jaytux.phoebench.common.ClientMonitorEvent
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.Stream
import com.jaytux.phoebench.common.bind
import com.jaytux.phoebench.common.fold
import io.ktor.http.HttpMethod
import io.ktor.utils.io.CancellationException
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Instant
object MonitorHandler {
private fun filterAnsi(line: String): Pair<String, List<ANSI>> {
val builder = StringBuilder()
val codes = mutableSetOf<ANSI>()
var remaining = line
while(remaining.isNotEmpty()) {
val next = remaining.indexOf("\u001B[")
if(next < 0) {
builder.append(remaining)
remaining = ""
}
else {
builder.append(remaining.substring(0, next))
val end = remaining.indexOf('m', startIndex = next + 2)
if(end == -1) return (line to listOf()) // invalid...
codes += remaining.substring(next + 2, end).split(';').mapNotNull {
it.toIntOrNull()?.let { i -> ANSI.ansiMapping[i] }
}
remaining = remaining.substring(end + 1)
}
}
return builder.toString() to codes.toList()
}
fun monitor(find: CLI.Commands.Project.IProjectIdentification?, command: String, args: List<String>) {
val project = ProjectHandlers.ensureProjectIdentification(find)
val combinedCommand = arrayOf(command, *args.toTypedArray())
tryAuthenticated {
project.toId().bind { id ->
Client.callCSE(Routes.CSE.monitor, id) { sender ->
try {
val res = process(
*combinedCommand,
stdin = null,
stdout = Redirect.Consume { flow ->
flow.collect { line ->
val (updLine, options) = filterAnsi(line)
sender(ClientMonitorEvent.Message(msg = updLine, stream = Stream.STDOUT, options = options))
println(line)
}
},
stderr = Redirect.Consume { flow ->
flow.collect { line ->
val (updLine, options) = filterAnsi(line)
sender(ClientMonitorEvent.Message(msg = updLine, stream = Stream.STDERR, options = options))
System.err.println(line)
}
}
)
sender(ClientMonitorEvent.ApplicationFinished(res.resultCode))
}
catch(e: CancellationException) {
throw e
}
catch(e: Exception) {
System.err.println("Launch failed: ${e.message} (${e::class.simpleName})")
}
}
}
}.fold({
System.err.println("Failed to run: ${it.msg}")
}) {}
}
}
@@ -2,6 +2,7 @@ package com.jaytux.phoebench.clients.cli
import com.github.javakeyring.BackendNotSupportedException
import com.github.javakeyring.Keyring
import com.github.javakeyring.PasswordAccessException
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
@@ -10,7 +11,7 @@ import kotlin.uuid.Uuid
object PersistentStorage {
private val json = Json
const val SERVICE = "com.jaytux.phoebench.cli"
const val SERVICE = PersistenceConstants.SERVICE
private var _backend: IBackend = KeyringBackend
interface IBackend {
@@ -22,7 +23,11 @@ object PersistentStorage {
private object KeyringBackend : IBackend {
override fun read(key: String): String? = try {
Keyring.create().use { it.getPassword(SERVICE, key) }
} catch(e: Exception) {
}
catch(e: PasswordAccessException) {
return null
}
catch(e: BackendNotSupportedException) {
_backend = PreferencesBackend
_backend.read(key)
}
@@ -52,6 +57,7 @@ object PersistentStorage {
override fun write(key: String, value: String) {
prefs.put(key, value)
prefs.flush()
}
override fun erase(key: String) {
@@ -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} versions:")
it.usedLabels.forEach { v ->
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 versioned ${map[e.labelId]?.name ?: "<invalid version>"} at ${e.timestamp.fmt()} " +
"(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})")
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.")
}
@@ -111,8 +118,25 @@ object ProjectHandlers {
}
}.fold({
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("Version ${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.")
}
}
}
+21 -14
View File
@@ -13,7 +13,10 @@ plugins {
alias(libs.plugins.buildconfig)
}
version = "1.0.1"
version = PhoebenchVersion(1, 2, 1, "autoscroll")
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("UI Clients version must be at least as high as protocol/common version")
val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val generateVersion = tasks.register<Task>("clientVersion") {
@@ -128,7 +131,7 @@ compose.desktop {
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.jaytux.phoebench.clients"
packageVersion = version.toString()
packageVersion = (version as PhoebenchVersion).safeString()
jvmArgs += listOf("--enable-native-access=ALL-UNNAMED")
includeAllModules = true
@@ -139,19 +142,23 @@ compose.desktop {
}
}
fun envValue(key: String): String? {
val fromEnv = providers.environmentVariable(key).orNull
if(fromEnv != null) return fromEnv
buildConfig {
generateAtSync = false
useKotlinOutput { internalVisibility = false }
val envFile = rootProject.file(".env")
if(!envFile.exists()) return null
println("Source sets: ${kotlin.sourceSets.toList().map { it.name }}")
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
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,9 +1,12 @@
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 io.ktor.client.*
import kotlinx.serialization.KSerializer
import kotlinx.serialization.serializer
import kotlin.uuid.Uuid
interface IStore {
@@ -27,3 +30,11 @@ 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?
@@ -1,9 +1,15 @@
package com.jaytux.phoebench.clients
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.jaytux.phoebench.common.Either
@@ -18,8 +24,10 @@ import kotlinx.datetime.format.MonthNames
import kotlinx.datetime.format.Padding
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.Font
import phoebench.clients.compose.generated.resources.*
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlin.math.pow
import kotlin.random.Random
import kotlin.random.nextInt
import kotlin.time.Clock
@@ -85,8 +93,21 @@ inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T
return toMutableList().apply { add(index, elem) }
}
fun List<Float>.geomean(): Float {
return (size.toFloat() / sumOf { 1.0 / it.toDouble() }).toFloat()
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)
@@ -123,3 +144,23 @@ fun <T1, T2, T3, T4> List<Tuple4<T1, T2, T3, T4>>.unzip(): Tuple4<List<T1>, List
return l1 to l2 app l3 app l4
}
fun <T> List<T>.removeAt(idx: Int): List<T> = subList(0, idx) + subList(idx + 1, size)
fun List<Float>.geomean(): Float {
var prod = 1.0f
for(value in this) prod *= value
return prod.pow(1.0f / size.toFloat())
}
@Composable
fun TextStyle.makeMonospaced(): TextStyle {
val family = FontFamily(
Font(Res.font.JetBrainsMono_Regular, weight = FontWeight.Normal, style = FontStyle.Normal),
Font(Res.font.JetBrainsMono_Bold, weight = FontWeight.Bold, style = FontStyle.Normal),
Font(Res.font.JetBrainsMono_Italic, weight = FontWeight.Normal, style = FontStyle.Italic),
Font(Res.font.JetBrainsMono_BoldItalic, weight = FontWeight.Bold, style = FontStyle.Italic)
)
return copy(fontFamily = family)
}
@@ -17,6 +17,7 @@ import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.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)
@@ -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,14 +26,20 @@ 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 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>
suspend fun deleteMonitorLogs(): Either<ErrorResponse, Unit>
companion object {
class Default(private val _client: Client, private val _projectId: Uuid) : IProjectRepo {
override suspend fun get(): Either<ErrorResponse, ProjectResponse> =
@@ -42,6 +51,15 @@ 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))
@@ -51,13 +69,16 @@ interface IProjectRepo {
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()
override suspend fun deleteMonitorLogs(): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.rmMonitor, _projectId).ignoreValue()
}
fun default(client: Client, projectId: Uuid) = Default(client, projectId)
@@ -7,12 +7,14 @@ import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.ServerMonitorEvent
import kotlin.uuid.Uuid
interface ISSERepo {
suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectMonitor(id: Uuid, onEvent: suspend (Either<ErrorResponse, ServerMonitorEvent>) -> Unit): Either<ErrorResponse, Unit>
companion object {
class Default(private val _client: Client) : ISSERepo {
@@ -24,6 +26,9 @@ interface ISSERepo {
override suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit> =
_client.callSSE(Routes.SSE.projectSpecific, id, onEvent)
override suspend fun connectMonitor(id: Uuid, onEvent: suspend (Either<ErrorResponse, ServerMonitorEvent>) -> Unit): Either<ErrorResponse, Unit> =
_client.callSSE(Routes.SSE.monitor, id, onEvent)
}
fun default(client: Client) = Default(client)
@@ -1,5 +1,6 @@
package com.jaytux.phoebench.clients.data
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
@@ -11,11 +12,16 @@ import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
import com.jaytux.phoebench.clients.hexString
import com.jaytux.phoebench.clients.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
import com.jaytux.phoebench.common.ServerMonitorEvent
import com.jaytux.phoebench.common.TimeUnit
import com.jaytux.phoebench.common.fold
import kotlinx.coroutines.Job
import kotlin.time.Clock
import kotlin.time.Instant
@@ -57,9 +63,20 @@ class ProjectVM(
data class Entry(val id: Uuid, val label: Version, val timeStamp: Instant, val warmups: List<Float>,
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,20 +86,25 @@ 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>())
private val _monitorMessages = mutableStateOf<List<ServerMonitorEvent.ITextEvent>?>(null)
val name = _name.immutable()
val owner = _owner.immutable()
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()
val monitorMessages = _monitorMessages.immutable()
private var _job: Job? = null
private var _monitorJob: Job? = null
init {
_job = withScope {
@@ -102,14 +124,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 +137,19 @@ 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.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.EntryDeleted -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmarkId }) { it.remove(event.id) }
is ProjectEvent.VersionChanged -> {
val updVersion = Version.fromResponse(event.label)
_labels.value += (event.label.id to updVersion)
_benchmarks.value = _benchmarks.value.map { b -> b.copy(entries = b.entries.map { e -> if(e.label.id == event.label.id) e.copy(label = updVersion) else e }) }
}
is ProjectEvent.VersionDeleted -> {
_labels.value = _labels.value.filter { it.value.id != event.id }
_benchmarks.value = _benchmarks.value.map { it.copy(entries = it.entries.filter { e -> e.label.id != event.id }) }
}
is ProjectEvent.NewEntry -> {
_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 +157,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
}
}
}
@@ -183,24 +224,102 @@ class ProjectVM(
}
}
fun mkEntry(label: Version, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit, input: String, hardware: String) {
fun mkBenchmark(name: String) {
withScope {
_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)
}
fun openMonitor() {
if(_monitorJob != null) return
_monitorMessages.value = listOf()
_monitorJob = withScope {
_sseRepo.connectMonitor(_id) { ev ->
ev.snackOr {
when(it) {
is ServerMonitorEvent.Backlog -> {
val res = ArrayList<ServerMonitorEvent.ITextEvent>(it.messages.size + 2)
it.start?.let { start -> res += start }
res.addAll(it.messages)
it.end?.let { end -> res += end }
_monitorMessages.value = res
}
ServerMonitorEvent.Cleared -> _monitorMessages.value = listOf()
is ServerMonitorEvent.ApplicationEnd, is ServerMonitorEvent.ApplicationStart, is ServerMonitorEvent.Message ->
_monitorMessages.value = (_monitorMessages.value ?: listOf()) + it
}
}
}
}
}
fun closeMonitor() {
_monitorJob?.cancel()
_monitorJob = null
_monitorMessages.value = null
}
fun clearMonitor() = withScope {
_repo.deleteMonitorLogs().snackOnError()
}
}
@@ -1,5 +1,24 @@
package com.jaytux.phoebench.clients.theme
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)
enum class UiMode(val minWidth: Dp) {
MOBILE(0.dp),
NARROW(0.dp),
WIDE(2000.dp);
companion object {
fun fromDp(width: Dp) = when {
width < NARROW.minWidth -> MOBILE
width < WIDE.minWidth -> NARROW
else -> WIDE
}
}
}
val GlobalUiMode = compositionLocalOf { UiMode.MOBILE }
@@ -25,6 +25,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.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.Scaffold
@@ -237,10 +238,12 @@ fun SignupView(server: String?, error: String?, onSwitchLogin: () -> Unit, onSig
@Composable
fun AuthenticatedRoot() {
val snacks = SnackProvider.get()
val auth = AuthProvider.get()
val state = remember { SnackbarHostState() }
val vm = viewModel { HomeVM() }
var loggingOut by remember { mutableStateOf(false) }
var currentProject by remember { mutableStateOf<Uuid?>(null) }
val server by auth.server
LaunchedEffect(snacks) {
snacks.snacks.collect { state.showSnackbar(it) }
@@ -254,7 +257,12 @@ fun AuthenticatedRoot() {
Scaffold(
topBar = {
TopAppBar(
title = { Text("PhoeBench v${ClientVersion.VERSION}", style = MaterialTheme.typography.headlineLarge) },
title = {
Column {
Text("PhoeBench v${ClientVersion.VERSION}", style = MaterialTheme.typography.headlineLarge)
server?.let { Text("Connected to $it", style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(0.6f)) }
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
@@ -320,7 +328,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())
}
}
}
}
@@ -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
)
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,75 @@
package com.jaytux.phoebench.clients
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.SwingPanel
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import com.jaytux.phoebench.clients.theme.GlobalUiMode
import com.jaytux.phoebench.clients.theme.UiMode
import java.awt.GraphicsConfiguration
import java.awt.GraphicsEnvironment
import java.awt.Toolkit
import java.awt.event.ComponentEvent
import java.awt.event.ComponentListener
import javax.swing.JPanel
fun windowData(config: GraphicsConfiguration): Pair<DpSize, DpOffset> {
val bounds = config.bounds
val insets = Toolkit.getDefaultToolkit().getScreenInsets(config)
val availableWidthPx = bounds.width - insets.left - insets.right
val availableHeightPx = bounds.height - insets.top - insets.bottom
val density = config.defaultTransform.scaleX.toFloat()
val size = DpSize((availableWidthPx / density).dp, (availableHeightPx / density).dp)
val offset = DpOffset((insets.left / density).dp, (insets.top / density).dp)
return size to offset
}
fun main() = application {
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") {
var width by remember { mutableStateOf(window.width.dp) }
LaunchedEffect(Unit) {
window.addComponentListener(object : ComponentListener {
override fun componentResized(e: ComponentEvent?) {
width = e?.component?.width?.dp ?: width
}
override fun componentMoved(e: ComponentEvent?) {}
override fun componentShown(e: ComponentEvent?) {}
override fun componentHidden(e: ComponentEvent?) {}
})
}
mode = UiMode.fromDp(width)
Column(Modifier.fillMaxSize()) {
SwingPanel(modifier = Modifier.size(0.dp), factory = {
JPanel().apply {
addPropertyChangeListener("graphicsConfiguration") {
val (newSize, newOffset) = windowData(this@Window.window.graphicsConfiguration)
state.size = newSize
state.position = WindowPosition.Aligned(Alignment.Center)
mode = UiMode.fromDp(state.size.width)
}
}
})
CompositionLocalProvider(GlobalUiMode provides mode) {
App()
}
}
}
}
@@ -2,6 +2,7 @@ package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draganddrop.*
import androidx.compose.ui.platform.ClipEntry
import com.github.javakeyring.Keyring
import com.jaytux.phoebench.clients.ui.DefaultServerSelect
@@ -10,12 +11,13 @@ 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,
@@ -59,7 +61,20 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
actual suspend fun String.toClipEntry(): ClipEntry =
ClipEntry(StringSelection(this))
actual fun format(f: Float): String = String.format("%.03f", f)
actual fun format(f: Float): String = String.format("%.03f", f).let { it.substring(0, minOf(it.length, 5)) }
@Composable
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,14 +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)
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()
}
}
}
@@ -1,6 +1,10 @@
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.*
@@ -9,9 +13,9 @@ import kotlinx.browser.window
import kotlinx.coroutines.await
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import org.w3c.dom.DataTransfer
import kotlin.uuid.Uuid
object WasmJsStore : IStore {
@@ -57,7 +61,7 @@ actual suspend fun String.toClipEntry(): ClipEntry =
@JsFun("(num) => num.toFixed(3)")
private external fun jsFormat(num: Double): String
actual fun format(f: Float): String = jsFormat(f.toDouble())
actual fun format(f: Float): String = jsFormat(f.toDouble()).let { it.substring(0, minOf(it.length, 5)) }
@Serializable
data class Config(val server: String)
@@ -75,3 +79,18 @@ lateinit var config: Config
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)
}
@@ -1,10 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PhoeBench</title>
<link type="text/css" rel="stylesheet" href="styles.css">
<link type="text/css" rel="stylesheet" href="styles.css" />
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-Regular.ttf" />
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-Bold.ttf" />
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-Italic.ttf" />
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-BoldItalic.ttf" />
<script type="application/javascript" src="composeApp.js"></script>
</head>
<body>
+4 -2
View File
@@ -11,12 +11,14 @@ 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)"
val scriptFile = project.file("partialize.main.kts")
val targets = fileTree(requestsDirectory) {
include("**/*.kt")
include("Requests.kt")
}
val lst = targets.map { it.absolutePath }
@@ -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())
}
@@ -0,0 +1,62 @@
package com.jaytux.phoebench.common
import io.ktor.client.plugins.websocket.*
import io.ktor.http.*
import io.ktor.util.reflect.*
import io.ktor.utils.io.*
import io.ktor.websocket.*
import kotlin.uuid.Uuid
sealed class CSERoute<TParams, TEvent: Any>(val path: String, val elevation: Elevation, private val _eventType: TypeInfo) {
open val pattern = path
protected open fun buildUrl(params: TParams): String = path
abstract fun extractParams(reqParams: Parameters): TParams?
suspend fun call(client: IClient, params: TParams, body: suspend (sender: suspend (TEvent) -> Unit) -> Unit): Either<ErrorResponse, Unit> {
val server = if(client.serverUrl.startsWith("http")) client.serverUrl.replace("http", "ws") else "ws://${client.serverUrl}"
var error: ErrorResponse? = null
try {
client.client.webSocket("$server${buildUrl(params)}", {}) {
try {
body { sendSerialized(it, _eventType) }
}
catch(e: CancellationException) {
closeReason.await()?.let {
if(it.code != CloseReason.Codes.NORMAL.code) error = ErrorResponse(it.message)
}
throw e
}
}
return Unit.value()
}
catch(e: CancellationException) {
return (error ?: ErrorResponse(e.message ?: "Unknown websocket error")).error()
}
catch(e: WebSocketException) {
return ErrorResponse("Could not set up websocket stream: ${e.message}").error()
}
catch(e: Exception) {
return ErrorResponse("WebSocket connection failed: ${e.message}").error()
}
}
class CSERoute1<T1, TEvent: Any>(path: String, elevation: Elevation, eventType: TypeInfo, val urlEncode: (T1) -> String, val urlDecode: (String?) -> T1?)
: CSERoute<T1, TEvent>(path, elevation, eventType)
{
override val pattern: String = "$path/{param}"
override fun buildUrl(params: T1): String = "$path/${urlEncode(params)}"
override fun extractParams(reqParams: Parameters): T1? = urlDecode(reqParams["param"])
}
companion object {
inline fun <T1, reified TEvent: Any> single(path: String, elevation: Elevation,
noinline urlEncode: (T1) -> String = { it.toString() }, noinline urlDecode: (String?) -> T1?
) = CSERoute1<T1, TEvent>(path, elevation, typeInfo<TEvent>(), urlEncode, urlDecode)
inline fun <reified TEvent: Any> uuid(path: String, elevation: Elevation) = single<Uuid, TEvent>(path, elevation) {
it?.let { p -> Uuid.parseOrNull(p) }
}
}
}
@@ -0,0 +1,8 @@
package com.jaytux.phoebench.common
enum class CloseReasons(val code: Short) {
NOT_AUTHORIZED(4001),
INVALID_REQUEST(4002),
CONFLICT(4003),
NOT_FOUND(4004)
}
@@ -1,6 +1,8 @@
package com.jaytux.phoebench.common
import kotlinx.serialization.Serializable
import kotlin.collections.fold
import kotlin.time.Instant
import kotlin.uuid.Uuid
@Serializable
@@ -42,6 +44,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 +66,82 @@ 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()
}
@Serializable
enum class Stream {
STDOUT, STDERR
}
@Serializable
enum class ANSI(val bitIdx: Int, val ansiCode: Int) {
BOLD(0, 1), UNDERLINE(1, 4),
FG_RED(2, 31), FG_GREEN(3, 32), FG_YELLOW(4, 33),
FG_BLUE(5, 34), FG_GRAY(6, 90),
BG_RED(7, 41), BG_GREEN(8, 42), BG_YELLOW(9, 43),
BG_BLUE(10, 44), BG_GRAY(11, 100);
companion object {
private val _mapping: Map<Int, ANSI>
val ansiMapping: Map<Int, ANSI>
init {
if(entries.map { it.bitIdx }.toSet().size != entries.size)
throw IllegalStateException("ANSI bit indices contain duplicates")
if(entries.map { it.ansiCode }.toSet().size != entries.size)
throw IllegalStateException("ANSI escape codes contain duplicates")
_mapping = entries.associateBy { ansi -> ansi.bitIdx }
ansiMapping = entries.associateBy { ansi -> ansi.ansiCode }
}
fun pack(vararg options: ANSI): UShort = options.fold(0u) { acc, ansi ->
acc or (1u shl ansi.bitIdx).toUShort()
}
fun unpack(packed: UShort): List<ANSI> {
val res = mutableListOf<ANSI>()
var remaining = packed
for(i in 0..15) {
if((remaining and 1u) != 0.toUShort()) {
_mapping[i]?.let { res += it }
}
remaining = (remaining.toUInt() shr 1).toUShort()
}
return res
}
}
}
@Serializable
sealed class ClientMonitorEvent {
@Serializable
data class Message(val msg: String, val stream: Stream, val options: UShort) : ClientMonitorEvent() {
constructor(msg: String, stream: Stream, vararg options: ANSI) : this(msg, stream, ANSI.pack(*options))
constructor(msg: String, stream: Stream, options: List<ANSI>) : this(msg, stream, ANSI.pack(*options.toTypedArray()))
}
@Serializable
data class ApplicationFinished(val exitCode: Int) : ClientMonitorEvent()
}
@Serializable
sealed class ServerMonitorEvent {
sealed interface ITextEvent
@Serializable
object Cleared : ServerMonitorEvent()
@Serializable
data class ApplicationStart(val time: Instant) : ServerMonitorEvent(), ITextEvent
@Serializable
data class Message(val timeStamp: Instant, val message: String, val options: UShort, val stream: Stream) : ServerMonitorEvent(), ITextEvent
@Serializable
data class Backlog(val start: ApplicationStart?, val messages: List<Message>, val end: ApplicationEnd?) : ServerMonitorEvent()
@Serializable
data class ApplicationEnd(val time: Instant, val exitCode: Int) : ServerMonitorEvent(), ITextEvent
}
@@ -19,13 +19,16 @@ class EmptyRequest
data class RefreshRequest(val refreshToken: Uuid)
@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
@@ -36,3 +39,9 @@ data class UserUpdateRequest(val projectLimit: Int? = null, val isAdmin: Boolean
@Serializable
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,12 +22,20 @@ 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)
val get = ApiRoute.getUuid<ProjectResponse>("/project", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialProjectRequest>("/project", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH)
val rmMonitor = ApiRoute.deleteUuidNoRes("/project/monitor", Elevation.AUTH)
}
object Benchmark {
val new = ApiRoute.post<BenchmarkRequest, BenchmarkResponse>("/benchmark", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialBenchmarkRequest>("/benchmark", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/benchmark", Elevation.AUTH)
}
object Version {
@@ -45,5 +53,10 @@ object Routes {
val home = SSERoute.noArgs<HomeEvent>("/rt/home", Elevation.AUTH)
val admin = SSERoute.noArgs<AdminEvent>("/rt/admin", Elevation.ADMIN)
val projectSpecific = SSERoute.uuid<ProjectEvent>("/rt/project", Elevation.AUTH)
val monitor = SSERoute.uuid<ServerMonitorEvent>("/rt/monitor", Elevation.AUTH)
}
object CSE {
val monitor = CSERoute.uuid<ClientMonitorEvent>("/stream/monitor", Elevation.AUTH)
}
}
+4
View File
@@ -23,6 +23,7 @@ kolor-picker = "2.1.0"
shadow = "9.3.0"
clikt = "5.0.3"
buildconfig = "6.0.10"
process = "1.5.1"
[libraries]
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
@@ -50,6 +51,7 @@ ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negoti
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-websocket = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" }
@@ -64,6 +66,7 @@ ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref =
ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" }
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" }
ktor-server-websocket = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
json = { module = "org.json:json", version.ref = "json" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
@@ -87,6 +90,7 @@ kolor = { module = "com.kborowy:kolor-picker", version.ref = "kolor-picker" }
java-keystore = { module = "com.github.javakeyring:java-keyring", version.ref = "java-keystore" }
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
process = { module = "com.github.pgreze:kotlin-process", version.ref = "process" }
[plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
+5 -1
View File
@@ -7,7 +7,10 @@ plugins {
}
group = "com.jaytux.phoebench"
version = rootProject.version
version = PhoebenchVersion(1, 2, 1, "patch1")
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("Server version must be at least as high as protocol/common version")
repositories {
mavenCentral()
@@ -53,6 +56,7 @@ dependencies {
implementation(libs.ktor.server.cors)
implementation(libs.ktor.server.status.pages)
implementation(libs.ktor.server.sse)
implementation(libs.ktor.server.websocket)
implementation(libs.ktor.serialization.kotlinx.json)
@@ -3,6 +3,8 @@ package com.jaytux.phoebench.server
import com.jaytux.phoebench.common.AdminEvent
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ServerMonitorEvent
import io.ktor.client.plugins.api.MonitoringEvent
import io.ktor.util.reflect.typeInfo
import kotlinx.serialization.serializer
import java.util.concurrent.ConcurrentHashMap
@@ -11,6 +13,10 @@ import kotlin.uuid.Uuid
object Buses {
private val _projectBuses = ConcurrentHashMap<Uuid, SSEBus<ProjectEvent>>()
private val _monitorBuses = ConcurrentHashMap<Uuid, SSEBus.MonitorSSEBus<
ServerMonitorEvent, ServerMonitorEvent.ApplicationStart, ServerMonitorEvent.Message,
ServerMonitorEvent.ApplicationEnd, ServerMonitorEvent.Backlog
>>()
val homeBus = SSEBus<HomeEvent>(typeOf<HomeEvent>(), serializer<HomeEvent>())
val adminBus = SSEBus<AdminEvent>(typeOf<AdminEvent>(), serializer<AdminEvent>())
@@ -19,9 +25,16 @@ object Buses {
SSEBus<ProjectEvent>(typeOf<ProjectEvent>(), serializer<ProjectEvent>())
}
fun monitorBus(id: Uuid) = _monitorBuses.computeIfAbsent(id) {
SSEBus.MonitorSSEBus(
SSEBus(typeOf<ServerMonitorEvent>(), serializer<ServerMonitorEvent>())
) { start, events, end -> ServerMonitorEvent.Backlog(start, events, end) }
}
fun allBuses(): List<SSEBus<*>> {
val res = ArrayList<SSEBus<*>>(_projectBuses.size + 2)
val res = ArrayList<SSEBus<*>>(_projectBuses.size + _monitorBuses.size + 2)
res.addAll(_projectBuses.values)
res.addAll(_monitorBuses.values.map { it.bus })
res.add(homeBus)
res.add(adminBus)
return res
@@ -2,10 +2,17 @@ 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.Project
import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.*
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProjectCSE
import io.ktor.http.*
import io.ktor.serialization.WebsocketContentConverter
import io.ktor.serialization.deserialize
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
@@ -19,32 +26,45 @@ import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sse.*
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.CloseReason
import io.ktor.websocket.close
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.time.Clock
import kotlin.uuid.Uuid
fun Application.module() {
DB.db
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(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
@@ -67,6 +87,12 @@ fun Application.module() {
}
}
install(SSE) {}
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
install(StatusPages) {
status(HttpStatusCode.Forbidden) { call, status ->
call.respond(status, ErrorResponse("Access Forbidden: CORS failed."))
@@ -120,11 +146,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)
deleteAuth(Routes.Project.rmMonitor, ProjectHandler::deleteMonitor)
postAuth(Routes.Benchmark.new, ProjectHandler::createBenchmark)
patchAuth(Routes.Benchmark.update, ProjectHandler::updateBenchmark)
deleteAuth(Routes.Benchmark.delete, ProjectHandler::deleteBenchmark)
postAuth(Routes.Version.new, ProjectHandler::createVersion)
patchAuth(Routes.Version.update, ProjectHandler::updateVersion)
@@ -133,13 +165,37 @@ fun Application.module() {
postAuth(Routes.Entry.new, ProjectHandler::createEntry)
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
sseAuth(Routes.SSE.home, { _, _ -> }) { _, _ -> Buses.homeBus }
sseAdmin(Routes.SSE.admin) { _, _ -> Buses.adminBus }
sseAuth(Routes.SSE.home, { _, _ -> }) { _, _, _ -> Buses.homeBus }
sseAdmin(Routes.SSE.admin) { _, _, _ -> Buses.adminBus }
sseAuth(Routes.SSE.projectSpecific,
{ user, uuid ->
transaction { accessibleProject(user, uuid, false) }
}
) { _, id -> Buses.projectBus(id) }
) { _, id, _ -> Buses.projectBus(id) }
sseAuth(Routes.SSE.monitor,
{ user, uuid ->
transaction { accessibleProject(user, uuid, true) }
}
) { _, id, sender ->
val bus = Buses.monitorBus(id)
val backlog = bus.onConnect()
sender(bus.bus.serializer, backlog)
bus.bus
}
cseAuth(Routes.CSE.monitor,
{ id, user -> transaction { accessibleProjectCSE(user, id, true) } },
ProjectHandler::monitorSetup, ProjectHandler::monitorTeardown) { _, _, bus, event ->
try {
when(event) {
is ClientMonitorEvent.ApplicationFinished -> bus.end(ServerMonitorEvent.ApplicationEnd(Clock.System.now(), event.exitCode))
is ClientMonitorEvent.Message -> bus.event(ServerMonitorEvent.Message(Clock.System.now(), event.msg, event.options, event.stream))
}
}
catch(e: IllegalStateException) {
throw RouteError.CSERouteError("Could not deliver message: ${e.message ?: "unknown error"}", CloseReasons.CONFLICT)
}
}
}
get("{...}") {
@@ -0,0 +1,35 @@
package com.jaytux.phoebench.server
class MutableBackLog<TStart, TEvent, TEnd> {
var start: TStart? = null
private set
private val _events = mutableListOf<TEvent>()
val events = _events.immutable()
var end: TEnd? = null
private set
fun registerStart(event: TStart) {
if(start != null) throw IllegalStateException("Start is already set.")
start = event
}
fun onEvent(event: TEvent) {
if(start == null) throw IllegalStateException("Start is not set yet.")
if(end != null) throw IllegalStateException("End is already set.")
_events += event
}
fun registerEnd(event: TEnd) {
if(start == null) throw IllegalStateException("Start is not set yet.")
if(end != null) throw IllegalStateException("End is already set.")
end = event
}
fun reset() {
start = null
_events.clear()
end = null
}
fun isRunning() = start != null && end == null
}
@@ -61,4 +61,32 @@ class SSEBus<T>(private val _containedType: KType, val serializer: KSerializer<T
map
}.forEach { it.second.emit(Cancellation.error()) }
}
class MonitorSSEBus<TSuper, TStart : TSuper, TEvent : TSuper, TEnd: TSuper, TBacklog: TSuper>(
val bus: SSEBus<TSuper>, val backlog: MutableBackLog<TStart, TEvent, TEnd> = MutableBackLog(),
val mkBacklog: (start: TStart?, events: List<TEvent>, end: TEnd?) -> TBacklog
) {
val start: TStart? get() = backlog.start
val events: List<TEvent> get() = backlog.events
val end: TEnd? get() = backlog.end
suspend fun start(start: TStart) {
backlog.registerStart(start)
bus.sendAll(start)
}
suspend fun event(event: TEvent) {
backlog.onEvent(event)
bus.sendAll(event)
}
suspend fun end(end: TEnd) {
backlog.registerEnd(end)
bus.sendAll(end)
}
suspend fun onConnect() = mkBacklog(start, events, end)
fun isRunning() = backlog.isRunning()
}
}
@@ -22,3 +22,7 @@ fun nowPlus(time: Int, unit: DateTimeUnit): Instant {
val now = Clock.System.now()
return now.plus(time, unit, systemTZ)
}
infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
fun <T> MutableList<T>.immutable(): List<T> = this
@@ -1,9 +1,11 @@
package com.jaytux.phoebench.server.db
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) {
@@ -72,3 +99,19 @@ 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)
@@ -51,3 +72,14 @@ object Entries : UuidTable() {
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)
}
@@ -4,18 +4,19 @@ import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrapped
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAdmin
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAuth
import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.CSERoute
import com.jaytux.phoebench.common.CloseReasons
import com.jaytux.phoebench.common.Elevation
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.SSERoute
import com.jaytux.phoebench.common.foldSuspend
import com.jaytux.phoebench.server.Auth.setup
import com.jaytux.phoebench.server.SSEBus
import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.RouteError
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.deserialize
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
@@ -31,10 +32,15 @@ import io.ktor.server.routing.delete
import io.ktor.server.routing.patch
import io.ktor.server.sse.heartbeat
import io.ktor.server.sse.sse
import io.ktor.server.websocket.webSocket
import io.ktor.sse.ServerSentEvent
import io.ktor.util.reflect.typeInfo
import io.ktor.utils.io.CancellationException
import io.ktor.websocket.CloseReason
import io.ktor.websocket.close
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.time.Duration.Companion.seconds
@@ -165,7 +171,7 @@ inline fun <reified TReq: Any, reified TRes: Any> Route.patchAdmin(api: ApiRoute
inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrapSSE(
api: SSERoute<TParams, TEvent>, noinline extra: suspend (ApplicationCall, TParams) -> TInter,
noinline prepare: suspend (TInter, TParams) -> SSEBus<TEvent>,
noinline prepare: suspend (TInter, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>,
noinline extract: suspend (SSEBus<TEvent>, TInter, TParams) -> SharedFlow<TFlow>,
noinline handler: suspend (TFlow, sender: suspend (TEvent) -> Unit) -> Unit,
noinline onCancel: suspend (SSEBus<TEvent>, TInter, TParams, CancellationException) -> Unit
@@ -182,11 +188,14 @@ inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrap
)
val inter = extra(call, params)
val bus = prepare(inter, params)
val bus = prepare(inter, params) { serializer, event -> send(ServerSentEvent(data = Json.encodeToString(serializer, event))) }
val stream = extract(bus, inter, params)
try {
stream.collect {
handler(it) { ev -> send(ServerSentEvent(data = Json.encodeToString(bus.serializer, ev))) }
handler(it) { ev ->
println("[SSE ${api.pattern}]: sending event!")
send(ServerSentEvent(data = Json.encodeToString(bus.serializer, ev)))
}
}
}
catch(e: CancellationException) {
@@ -214,7 +223,7 @@ inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrap
inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
api: SSERoute<TParams, TEvent>,
noinline verifyUser: suspend (User, TParams) -> Unit,
noinline prepare: suspend (User, TParams) -> SSEBus<TEvent>
noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
) = wrapSSE(api,
extra = { call, params ->
val principal = call.principal<JWTPrincipal>()
@@ -239,23 +248,123 @@ inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
onCancel = { bus, user, _, _ -> bus.disconnect(user.id.value) }
)
inline fun <reified TParams: Any, reified TEvent: Any> Route.sse(api: SSERoute<TParams, TEvent>, noinline setup: suspend (TParams) -> SSEBus<TEvent>) =
wrapSSE(api,
inline fun <reified TParams: Any, reified TEvent: Any> Route.sse(api: SSERoute<TParams, TEvent>, noinline prepare: suspend (TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>) {
if(api.elevation != Elevation.UN_AUTH) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
wrapSSE(
api,
extra = { _, _ -> },
prepare = { _, params -> setup(params) },
prepare = { _, params, sender -> prepare(params, sender) },
extract = { bus, _, _ -> bus.unRegistered() },
handler = { it, sender -> sender(it) },
onCancel = { _, _, _, _ -> }
)
}
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAuth(api: SSERoute<TParams, TEvent>,
noinline verifyUser: suspend (User, TParams) -> Unit, noinline setup: suspend (User, TParams) -> SSEBus<TEvent>
) = wrapAuthSSE(api, verifyUser, setup)
noinline verifyUser: suspend (User, TParams) -> Unit, noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
) {
if(api.elevation != Elevation.AUTH) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthSSE(api, verifyUser, prepare)
}
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAdmin(api: SSERoute<TParams, TEvent>,
noinline setup: suspend (User, TParams) -> SSEBus<TEvent>
) = wrapAuthSSE(api, { user, _ ->
if(!user.isAdmin) {
throw RouteError("Admin access required", HttpStatusCode.Forbidden)
noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
) {
if(api.elevation != Elevation.ADMIN) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthSSE(api, { user, _ ->
if(!user.isAdmin) {
throw RouteError("Admin access required", HttpStatusCode.Forbidden)
}
}, prepare)
}
inline fun <reified TParams: Any, reified TEvent: Any, TInter, TExtra> Route.wrapCSE(
api: CSERoute<TParams, TEvent>,
noinline extra: suspend (ApplicationCall, TParams) -> TInter,
noinline setup: suspend (TParams, TInter) -> TExtra,
noinline onClose: suspend (TParams, TInter, TExtra) -> Unit,
noinline handler: suspend (TParams, TInter, TExtra, TEvent) -> Unit
) {
webSocket(api.pattern) {
try {
val params = api.extractParams(call.parameters) ?:
throw RouteError.CSERouteError("Missing or malformed parameters for CSE ${api.pattern}", CloseReasons.INVALID_REQUEST)
val inter = extra(call, params)
val converter = KotlinxWebsocketSerializationConverter(Json)
val extra = setup(params, inter)
try {
for (frame in incoming) {
handler(params, inter, extra, converter.deserialize<TEvent>(frame))
}
}
finally {
onClose(params, inter, extra)
}
}
catch(e: RouteError.CSERouteError) {
close(CloseReason(e.code, e.message ?: "Unknown error"))
}
catch(e: Exception) {
close(CloseReason(CloseReason.Codes.INTERNAL_ERROR, e.message ?: "Unknown error"))
}
}
}, setup)
}
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.wrapAuthCSE(
api: CSERoute<TParams, TEvent>,
noinline verifyUser: suspend (TParams, User) -> Unit,
noinline setup: suspend (TParams, User) -> TExtra,
noinline onClose: suspend (TParams, User, TExtra) -> Unit,
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
) = wrapCSE<TParams, TEvent, User, TExtra>(
api = api,
extra = { call, params ->
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.getClaim(com.jaytux.phoebench.common.Auth.JWT_CLAIM)?.asString()
?: throw RouteError.CSERouteError("Missing user claim", CloseReasons.NOT_AUTHORIZED)
val user = transaction {
User.findById(Uuid.parse(userId)) ?: throw RouteError.CSERouteError(
"User not found",
CloseReasons.NOT_AUTHORIZED
)
}
verifyUser(params, user)
user
},
setup = setup,
onClose = onClose,
handler = handler
)
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cse(
api: CSERoute<TParams, TEvent>,
noinline setup: suspend (TParams) -> TExtra,
noinline onClose: suspend (TParams, TExtra) -> Unit,
noinline handler: suspend (TParams, TExtra, TEvent) -> Unit
) {
if(api.elevation != Elevation.UN_AUTH) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
wrapCSE(api, { _, _ -> }, { it, _ -> setup(it) }, { p, _, extra -> onClose(p, extra) }) { params, _, extra, event -> handler(params, extra, event) }
}
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cseAuth(
api: CSERoute<TParams, TEvent>, noinline verifyUser: suspend (TParams, User) -> Unit,
noinline setup: suspend (TParams, User) -> TExtra, noinline onClose: suspend (TParams, User, TExtra) -> Unit,
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
) {
if(api.elevation != Elevation.AUTH) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthCSE(api, verifyUser, setup, onClose, handler)
}
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cseAdmin(
api: CSERoute<TParams, TEvent>, noinline setup: suspend (TParams, User) -> TExtra,
noinline onClose: suspend (TParams, User, TExtra) -> Unit,
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
) {
if(api.elevation != Elevation.ADMIN) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthCSE(api, { _, user ->
if(!user.isAdmin) {
throw RouteError.CSERouteError("Admin access required", CloseReasons.NOT_AUTHORIZED)
}
}, setup, onClose, handler)
}
@@ -1,22 +1,35 @@
package com.jaytux.phoebench.server.handlers
import com.jaytux.phoebench.common.BenchmarkRequest
import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.BenchmarkSummary
import com.jaytux.phoebench.common.ClientMonitorEvent
import com.jaytux.phoebench.common.CloseReasons
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.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.common.ServerMonitorEvent
import com.jaytux.phoebench.server.Buses
import com.jaytux.phoebench.server.SSEBus
import com.jaytux.phoebench.server.app
import com.jaytux.phoebench.server.db.Benchmark
import com.jaytux.phoebench.server.db.Benchmarks
import com.jaytux.phoebench.server.db.Entries
import com.jaytux.phoebench.server.db.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,8 +40,14 @@ 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.time.Clock
import kotlin.uuid.Uuid
object ProjectHandler {
@@ -40,23 +59,49 @@ 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.accessibleProjectCSE(user: User, id: Uuid, forEditing: Boolean): Project {
val project = Project.findById(id) ?: throw RouteError.CSERouteError("Invalid project ID.", CloseReasons.NOT_FOUND)
return project.isAccessible(user, forEditing)
}
fun Transaction.accessibleBenchmark(user: User, id: Uuid, forEditing: Boolean): Pair<Project, Benchmark> {
val bench = Benchmark.findById(id) ?: throw RouteError("Invalid benchmark ID.", HttpStatusCode.NotFound)
return bench.isAccessible(user, forEditing)
}
fun Transaction.checkMigration(user: User) {
if(user.projectVersion < Projects.CURRENT_VERSION)
throw RouteError("You need to migrate your projects first.", HttpStatusCode.Conflict)
}
context(trns: Transaction)
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 +109,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 +146,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 +173,7 @@ object ProjectHandler {
}
fun deleteProject(user: User, req: Uuid) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req, true)
proj.delete()
ServerScope.launch {
@@ -124,8 +185,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 +246,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 +274,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 +287,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 +304,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,11 +313,111 @@ 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())
}
suspend fun monitorSetup(id: Uuid, user: User) = Buses.monitorBus(id).also {
try {
if(!it.isRunning()) it.backlog.reset()
it.start(ServerMonitorEvent.ApplicationStart(Clock.System.now()))
}
catch(e: IllegalStateException) {
throw RouteError.CSERouteError("Monitor may already be running. Please refrain from starting a second trace.", CloseReasons.CONFLICT)
}
}
suspend fun monitorTeardown(
id: Uuid, user: User,
bus: SSEBus.MonitorSSEBus<ServerMonitorEvent, ServerMonitorEvent.ApplicationStart, ServerMonitorEvent.Message,
ServerMonitorEvent.ApplicationEnd, ServerMonitorEvent.Backlog>
) {
bus.end(ServerMonitorEvent.ApplicationEnd(Clock.System.now(), -1))
}
fun deleteMonitor(user: User, req: Uuid) = transaction {
checkMigration(user)
accessibleProject(user, req, true)
val bus = Buses.monitorBus(req)
if(bus.isRunning()) throw RouteError("Monitor is still running", HttpStatusCode.Conflict)
bus.backlog.reset()
ServerScope.launch {
bus.bus.sendAll(ServerMonitorEvent.Cleared)
}
success(EmptyResponse())
}
@@ -1,6 +1,7 @@
package com.jaytux.phoebench.server.handlers
import com.jaytux.phoebench.common.Auth
import com.jaytux.phoebench.common.CloseReasons
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.server.db.User
import io.ktor.http.ContentType
@@ -13,6 +14,7 @@ import io.ktor.server.response.respondText
import io.ktor.server.routing.RoutingCall
import io.ktor.server.routing.RoutingContext
import io.ktor.util.logging.KtorSimpleLogger
import io.ktor.websocket.CloseReason
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.uuid.Uuid
@@ -75,4 +77,9 @@ open class RouteError(message: String, val status: HttpStatusCode = HttpStatusCo
fun unauthorized(message: String): Nothing =
throw RouteError(message, HttpStatusCode.Unauthorized)
}
class CSERouteError(message: String, val code: Short) : RouteError(message, HttpStatusCode.NotImplemented) {
constructor(message: String, code: CloseReason.Codes) : this(message, code.code)
constructor(message: String, code: CloseReasons) : this(message, code.code)
}
}