14 Commits
Author SHA1 Message Date
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 2666 additions and 432 deletions
+3
View File
@@ -45,7 +45,10 @@ bin/
.DS_Store .DS_Store
*.db *.db
*.bak*
.env .env
.env.*
*.sql
.idea/ .idea/
artifacts/ artifacts/
+4 -2
View File
@@ -2,6 +2,8 @@
set -e set -e
export PHOEBENCH_BUILD_RELEASE='true'
./gradlew :clients:cli:shadowJar ./gradlew :clients:cli:shadowJar
./gradlew :clients:compose:createDistributable ./gradlew :clients:compose:createDistributable
./gradlew :clients:compose:wasmJsBrowserProductionWebpack ./gradlew :clients:compose:wasmJsBrowserProductionWebpack
@@ -14,8 +16,8 @@ cp clients/cli/build/libs/phoebench-cli.jar artifacts/
cp clients/compose/build/compose/binaries/main/app/com.jaytux.phoebench.clients/phoebench-linux.zip artifacts/ cp clients/compose/build/compose/binaries/main/app/com.jaytux.phoebench.clients/phoebench-linux.zip artifacts/
mkdir -p artifacts/wasm mkdir -p artifacts/wasm
cp clients/compose/build/kotlin-webpack/wasmJs/productionExecutable/* clients/compose/src/wasmJsMain/resources/* artifacts/wasm cp -r clients/compose/build/kotlin-webpack/wasmJs/productionExecutable/* clients/compose/build/processedResources/wasmJs/main/* artifacts/wasm
(cd artifacts/wasm && zip phoebench-wasm.zip -r ./*) (cd artifacts/wasm && rm config.json && zip phoebench-wasm.zip -r ./*)
mv artifacts/wasm/phoebench-wasm.zip artifacts/ mv artifacts/wasm/phoebench-wasm.zip artifacts/
rm -fr artifacts/wasm rm -fr artifacts/wasm
+3 -1
View File
@@ -1,3 +1,5 @@
import PhoebenchVersion
plugins { plugins {
alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.composeCompiler) apply false
@@ -12,4 +14,4 @@ repositories {
mavenCentral() mavenCentral()
} }
version = "1.0.0" version = PhoebenchVersion(1, 2, 1, "")
+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 application
alias(libs.plugins.serialization) alias(libs.plugins.serialization)
alias(libs.plugins.shadow) alias(libs.plugins.shadow)
alias(libs.plugins.buildconfig)
} }
group = "com.jaytux.phoebench" group = "com.jaytux.phoebench"
version = "1.0.1" version = rootProject.version as PhoebenchVersion
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("CLI Client version must be at least as high as protocol/common version")
val versionDirectory = layout.buildDirectory.dir("generated/sources/version") val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val generateVersion = tasks.register<Task>("serverVersion") { val generateVersion = tasks.register<Task>("serverVersion") {
@@ -25,12 +29,15 @@ val generateVersion = tasks.register<Task>("serverVersion") {
} }
} }
val isDebug = envValue("PHOEBENCH_BUILD_RELEASE") == null
dependencies { dependencies {
implementation(kotlin("stdlib")) implementation(kotlin("stdlib"))
implementation(libs.clikt) implementation(libs.clikt)
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.auth) implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.websocket)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization) implementation(libs.kotlinx.serialization)
implementation(project(":common")) implementation(project(":common"))
@@ -39,6 +46,7 @@ dependencies {
implementation(libs.slf4j.simple) implementation(libs.slf4j.simple)
implementation(libs.java.keystore) implementation(libs.java.keystore)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.process)
} }
application { application {
@@ -72,5 +80,26 @@ kotlin {
} }
tasks.withType<ShadowJar> { tasks.withType<ShadowJar> {
archiveFileName = "phoebench-cli.jar" val archive = if(isDebug) "phoebench-cli-debug.jar" else "phoebench-cli.jar"
println("Using archive '$archive'")
archiveFileName = archive
}
buildConfig {
generateAtSync = false
useKotlinOutput { internalVisibility = false }
println("Source sets: ${kotlin.sourceSets.toList().map { it.name }}")
className("PersistenceConstants")
packageName("com.jaytux.phoebench.clients.cli")
if(isDebug) {
println("Using debug CLI service")
buildConfigField("SERVICE", "com.jaytux.phoebench.cli.debug")
}
else {
println("Using release CLI service")
buildConfigField("SERVICE", "com.jaytux.phoebench.cli")
}
} }
@@ -6,16 +6,21 @@ import kotlin.system.exitProcess
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
object AuthHandlers { object AuthHandlers {
suspend fun checkServer() =
Client.callRoute(Routes.handshake, EmptyRequest()).bind { handshake ->
if(handshake.version != ProtocolVersion.VERSION) {
val server = Client.getServer() ?: "<unknown server ULR>"
Client.onClearServer()
ErrorResponse("Cannot use $server: protocol version mismatch (server: ${handshake.version}, expected: ${ProtocolVersion.VERSION})").error()
}
Unit.value()
}
fun serverPrompt(server: String?): Either<ErrorResponse, Unit> { fun serverPrompt(server: String?): Either<ErrorResponse, Unit> {
val useServer = server.maybePrompt("server") { it } val useServer = server.maybePrompt("server") { it }
Client.onSelectServer(useServer) Client.onSelectServer(useServer)
return runBlocking { return runBlocking {
Client.callRoute(Routes.handshake, EmptyRequest()).bind { handshake -> checkServer().bind { Client.onSelectServer(useServer).value() }
if (handshake.version != ProtocolVersion.VERSION) {
Client.onClearServer()
ErrorResponse("Mismatched protocol version (server: ${handshake.version}, client: ${ProtocolVersion.VERSION})").error()
} else Unit.value()
}
} }
} }
@@ -1,6 +1,8 @@
package com.jaytux.phoebench.clients.cli package com.jaytux.phoebench.clients.cli
import com.github.ajalt.clikt.core.* import com.github.ajalt.clikt.core.*
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.multiple
import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions
import com.github.ajalt.clikt.parameters.groups.single import com.github.ajalt.clikt.parameters.groups.single
import com.github.ajalt.clikt.parameters.options.* import com.github.ajalt.clikt.parameters.options.*
@@ -101,10 +103,11 @@ object CLI {
override fun nestedCommands(): Array<CliktCommand> = fromReflection() override fun nestedCommands(): Array<CliktCommand> = fromReflection()
sealed interface IProjectIdentification sealed interface IProjectIdentification
sealed interface ILabelIdentification sealed interface IVersionIdentification
sealed interface IBenchmarkIdentification
data class ProjectName(val user: String, val project: String) : IProjectIdentification data class ProjectName(val user: String, val project: String) : IProjectIdentification
data class VersionName(val name: String): ILabelIdentification data class Name(val name: String): IVersionIdentification, IBenchmarkIdentification
data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification data class ID(val id: Uuid) : IProjectIdentification, IVersionIdentification, IBenchmarkIdentification
sealed interface IData<T> { sealed interface IData<T> {
fun toList(): List<T> fun toList(): List<T>
@@ -184,12 +187,24 @@ object CLI {
override fun run() = ProjectHandlers.newVersion(name, color, parent.finder, meta) override fun run() = ProjectHandlers.newVersion(name, color, parent.finder, meta)
} }
@Suppress("unused")
class AddBenchmark : CliktCommand(name = "add-benchmark") {
val parent by requireObject<Project>()
val name by option("--name", help = "Set the version's name (*)")
override fun run() = ProjectHandlers.newBenchmark(name, parent.finder)
}
@Suppress("unused") @Suppress("unused")
class AddData : CliktCommand(name = "add-data") { class AddData : CliktCommand(name = "add-data") {
val parent by requireObject<Project>() val parent by requireObject<Project>()
val version by mutuallyExclusiveOptions<ILabelIdentification>( val version by mutuallyExclusiveOptions<IVersionIdentification>(
option("--version-id", help = "Set the version by UUID.").convert { ID(Uuid.parse(it)) }, option("--version-id", help = "Set the version by UUID.").convert { ID(Uuid.parse(it)) },
option("--version", help = "Set the version by name.").convert { VersionName(it) } option("--version", help = "Set the version by name.").convert { Name(it) }
).single()
val benchmark by mutuallyExclusiveOptions<IBenchmarkIdentification>(
option("--benchmark-id", "--bench-id", help = "Set the benchmark by UUID.").convert { ID(Uuid.parse(it)) },
option("--benchmark", "--bench", help = "Set the benchmark by name.").convert { Name(it) }
).single() ).single()
val warmup by mutuallyExclusiveOptions<IData<Float>>( val warmup by mutuallyExclusiveOptions<IData<Float>>(
option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) }, option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
@@ -210,7 +225,25 @@ object CLI {
val input by option("--input", help = "Set the input used to benchmark") val input by option("--input", help = "Set the input used to benchmark")
val hardware by option("--hardware", "--hw", help = "Set the hardware on which the benchmark was run") val hardware by option("--hardware", "--hw", help = "Set the hardware on which the benchmark was run")
override fun run() = ProjectHandlers.newData(parent.finder, version, warmup, measurement, unit, input, hardware) override fun run() = ProjectHandlers.newData(parent.finder, benchmark, version, warmup, measurement, unit, input, hardware)
}
}
@Suppress("unused")
class Monitor : CliktCommand(name = "monitor") {
val finder by mutuallyExclusiveOptions<Project.IProjectIdentification>(
option("--id", help = "Find a project by UUID.").convert { Project.ID(Uuid.parse(it)) },
option("--name", help = "Find a project by name (formatted [user]/[project])").convert {
val split = it.split('/')
if(split.size != 2) throw IllegalArgumentException("Invalid format (expected [user]/[project])")
Project.ProjectName(split[0], split[1])
}
).single()
val command by argument("command", help = "The command to be run")
val commandArgs by argument("arguments", help = "Arguments to pass to the command").multiple()
override fun run() {
MonitorHandler.monitor(finder, command, commandArgs)
} }
} }
@@ -1,6 +1,7 @@
package com.jaytux.phoebench.clients.cli package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.common.ApiRoute import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.CSERoute
import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.ErrorResponse import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.IClient import com.jaytux.phoebench.common.IClient
@@ -9,15 +10,31 @@ import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.TokenResponse import com.jaytux.phoebench.common.TokenResponse
import com.jaytux.phoebench.common.error import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.foldSuspend import com.jaytux.phoebench.common.foldSuspend
import com.jaytux.phoebench.common.value
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.ResponseException
import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.websocket.WebSocketException
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.sendSerialized
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.url
import io.ktor.http.HttpMethod
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import io.ktor.util.reflect.TypeInfo
import io.ktor.util.reflect.typeInfo
import io.ktor.utils.io.CancellationException import io.ktor.utils.io.CancellationException
import io.ktor.websocket.CloseReason
import kotlinx.coroutines.asExecutor import kotlinx.coroutines.asExecutor
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import java.net.URL
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
object Client { object Client {
@@ -68,6 +85,10 @@ object Client {
} }
} }
} }
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
} }
init { init {
@@ -91,6 +112,19 @@ object Client {
suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> = suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> =
callRoute(_client, route, body) callRoute(_client, route, body)
suspend fun <TParams: Any, TEvent: Any> callCSE(
route: CSERoute<TParams, TEvent>, params: TParams,
body: suspend (sender: suspend (TEvent) -> Unit) -> Unit
): Either<ErrorResponse, Unit> {
try {
val client = IClient.Default(_client, _server ?: throw IllegalStateException("No server URL set."))
return route.call(client, params, body)
}
catch(e: CancellationException) {
return ErrorResponse("Event stream disconnected.").error()
}
}
fun onLogin(tokens: TokenResponse) { fun onLogin(tokens: TokenResponse) {
_refreshToken = tokens.refresh _refreshToken = tokens.refresh
_accessToken = tokens.access _accessToken = tokens.access
@@ -0,0 +1,83 @@
package com.jaytux.phoebench.clients.cli
import com.github.pgreze.process.Redirect
import com.github.pgreze.process.process
import com.jaytux.phoebench.clients.cli.ProjectHandlers.toId
import com.jaytux.phoebench.common.ANSI
import com.jaytux.phoebench.common.ClientMonitorEvent
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.Stream
import com.jaytux.phoebench.common.bind
import com.jaytux.phoebench.common.fold
import io.ktor.http.HttpMethod
import io.ktor.utils.io.CancellationException
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Instant
object MonitorHandler {
private fun filterAnsi(line: String): Pair<String, List<ANSI>> {
val builder = StringBuilder()
val codes = mutableSetOf<ANSI>()
var remaining = line
while(remaining.isNotEmpty()) {
val next = remaining.indexOf("\u001B[")
if(next < 0) {
builder.append(remaining)
remaining = ""
}
else {
builder.append(remaining.substring(0, next))
val end = remaining.indexOf('m', startIndex = next + 2)
if(end == -1) return (line to listOf()) // invalid...
codes += remaining.substring(next + 2, end).split(';').mapNotNull {
it.toIntOrNull()?.let { i -> ANSI.ansiMapping[i] }
}
remaining = remaining.substring(end + 1)
}
}
return builder.toString() to codes.toList()
}
fun monitor(find: CLI.Commands.Project.IProjectIdentification?, command: String, args: List<String>) {
val project = ProjectHandlers.ensureProjectIdentification(find)
val combinedCommand = arrayOf(command, *args.toTypedArray())
tryAuthenticated {
project.toId().bind { id ->
Client.callCSE(Routes.CSE.monitor, id) { sender ->
try {
val res = process(
*combinedCommand,
stdin = null,
stdout = Redirect.Consume { flow ->
flow.collect { line ->
val (updLine, options) = filterAnsi(line)
sender(ClientMonitorEvent.Message(msg = updLine, stream = Stream.STDOUT, options = options))
println(line)
}
},
stderr = Redirect.Consume { flow ->
flow.collect { line ->
val (updLine, options) = filterAnsi(line)
sender(ClientMonitorEvent.Message(msg = updLine, stream = Stream.STDERR, options = options))
System.err.println(line)
}
}
)
sender(ClientMonitorEvent.ApplicationFinished(res.resultCode))
}
catch(e: CancellationException) {
throw e
}
catch(e: Exception) {
System.err.println("Launch failed: ${e.message} (${e::class.simpleName})")
}
}
}
}.fold({
System.err.println("Failed to run: ${it.msg}")
}) {}
}
}
@@ -2,6 +2,7 @@ package com.jaytux.phoebench.clients.cli
import com.github.javakeyring.BackendNotSupportedException import com.github.javakeyring.BackendNotSupportedException
import com.github.javakeyring.Keyring import com.github.javakeyring.Keyring
import com.github.javakeyring.PasswordAccessException
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
@@ -10,7 +11,7 @@ import kotlin.uuid.Uuid
object PersistentStorage { object PersistentStorage {
private val json = Json private val json = Json
const val SERVICE = "com.jaytux.phoebench.cli" const val SERVICE = PersistenceConstants.SERVICE
private var _backend: IBackend = KeyringBackend private var _backend: IBackend = KeyringBackend
interface IBackend { interface IBackend {
@@ -22,7 +23,11 @@ object PersistentStorage {
private object KeyringBackend : IBackend { private object KeyringBackend : IBackend {
override fun read(key: String): String? = try { override fun read(key: String): String? = try {
Keyring.create().use { it.getPassword(SERVICE, key) } Keyring.create().use { it.getPassword(SERVICE, key) }
} catch(e: Exception) { }
catch(e: PasswordAccessException) {
return null
}
catch(e: BackendNotSupportedException) {
_backend = PreferencesBackend _backend = PreferencesBackend
_backend.read(key) _backend.read(key)
} }
@@ -1,6 +1,7 @@
package com.jaytux.phoebench.clients.cli package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.clients.cli.CLI.Commands.Project.ProjectName import com.jaytux.phoebench.clients.cli.CLI.Commands.Project.ProjectName
import com.jaytux.phoebench.common.BenchmarkRequest
import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.EmptyRequest import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.EntryRequest import com.jaytux.phoebench.common.EntryRequest
@@ -29,6 +30,7 @@ object ProjectHandlers {
Client.callRoute(Routes.home, EmptyRequest()) Client.callRoute(Routes.home, EmptyRequest())
}.fold({ err -> }.fold({ err ->
System.err.println("Could not get projects list: ${err.msg}") System.err.println("Could not get projects list: ${err.msg}")
exitProcess(-1)
}) { }) {
System.err.println("Logged in as ${it.username}") System.err.println("Logged in as ${it.username}")
(it.ownProjects + it.publicProjects).toSet().sortedBy { p -> p.name }.forEach { project -> (it.ownProjects + it.publicProjects).toSet().sortedBy { p -> p.name }.forEach { project ->
@@ -65,18 +67,22 @@ object ProjectHandlers {
} }
}.fold({ }.fold({
System.err.println("Failed to get project details: ${it.msg}") System.err.println("Failed to get project details: ${it.msg}")
exitProcess(-1)
}) { }) {
println("Project ${it.owner.name}/${it.name} [${it.id}]:") println("Project ${it.owner.name}/${it.name} [${it.id}]:")
println("${it.usedLabels.size} versions:") println("${it.versions.size} versions:")
it.usedLabels.forEach { v -> it.versions.forEach { v ->
println(" - [${v.id}] VERSION ${v.name} (with color ${v.color}); metadata: ${v.meta}") println(" - [${v.id}] VERSION ${v.name} (with color ${v.color}); metadata: ${v.meta}")
} }
val map = it.usedLabels.associateBy { l -> l.id } val map = it.versions.associateBy { l -> l.id }
println("\n${it.entries.size} entries:") println("\n${it.benchmarks.size} benchmarks:")
it.entries.forEach { e -> it.benchmarks.forEach { b ->
println(" - [${e.id}] ENTRY versioned ${map[e.labelId]?.name ?: "<invalid version>"} at ${e.timestamp.fmt()} " + println(" - [${b.id}] BENCHMARK ${b.name} (with ${b.entries.size} entries):")
"(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})") 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)) Client.callRoute(Routes.Project.new, ProjectRequest(useName, isPublic))
}.fold({ }.fold({
System.err.println("Failed to create project '$useName': ${it.msg}") System.err.println("Failed to create project '$useName': ${it.msg}")
exitProcess(-1)
}) { }) {
println("Project ${it.owner.name}/${it.name} [${it.id}] created.") println("Project ${it.owner.name}/${it.name} [${it.id}] created.")
} }
@@ -111,8 +118,25 @@ object ProjectHandlers {
} }
}.fold({ }.fold({
System.err.println("Could not create version: ${it.msg}") System.err.println("Could not create version: ${it.msg}")
exitProcess(-1)
}) { }) {
println("Label ${it.name} (${it.color}) [${it.id}] created.") println("Version ${it.name} (${it.color}) [${it.id}] created.")
}
}
fun newBenchmark(name: String?, project: CLI.Commands.Project.IProjectIdentification?) {
val id = ensureProjectIdentification(project)
val useName = name.maybePrompt("version name") { it }
tryAuthenticated {
id.toId().bind { projectId ->
Client.callRoute(Routes.Benchmark.new, BenchmarkRequest(useName, projectId))
}
}.fold({
System.err.println("Could not create benchmark: ${it.msg}")
exitProcess(-1)
}) {
println("Benchmark ${it.name} [${it.id}] created.")
} }
} }
@@ -129,14 +153,18 @@ object ProjectHandlers {
fun newData( fun newData(
project: CLI.Commands.Project.IProjectIdentification?, project: CLI.Commands.Project.IProjectIdentification?,
label: CLI.Commands.Project.ILabelIdentification?, benchmark: CLI.Commands.Project.IBenchmarkIdentification?,
label: CLI.Commands.Project.IVersionIdentification?,
warmup: CLI.Commands.Project.IData<Float>?, warmup: CLI.Commands.Project.IData<Float>?,
measurement: CLI.Commands.Project.IData<Float>?, measurement: CLI.Commands.Project.IData<Float>?,
unit: TimeUnit?, input: String?, hardware: String? unit: TimeUnit?, input: String?, hardware: String?
) { ) {
val projectId = ensureProjectIdentification(project) val projectId = ensureProjectIdentification(project)
val versionId = label.maybePrompt("version name") { val versionId = label.maybePrompt("version name") {
CLI.Commands.Project.VersionName(it) CLI.Commands.Project.Name(it)
}
val benchmarkId = benchmark.maybePrompt("benchmark name") {
CLI.Commands.Project.Name(it)
} }
val warmupData = warmup.ensure("warmup").toList() val warmupData = warmup.ensure("warmup").toList()
val measureData = measurement.ensure("measurements").toList() val measureData = measurement.ensure("measurements").toList()
@@ -160,19 +188,30 @@ object ProjectHandlers {
tryAuthenticated { tryAuthenticated {
projectId.toId().bind { pId -> projectId.toId().bind { pId ->
when(versionId) { Client.callRoute(Routes.Project.get, pId).bind { p ->
is CLI.Commands.Project.ID -> versionId.id.value() when (versionId) {
is CLI.Commands.Project.VersionName -> Client.callRoute(Routes.Project.get, pId).bind { is CLI.Commands.Project.ID -> versionId.id.value()
it.usedLabels.firstOrNull { l -> l.name == versionId.name }?.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() ?: ErrorResponse("Version ${versionId.name} does not exist in this project.").error()
}.bind { vId ->
when (benchmarkId) {
is CLI.Commands.Project.ID -> benchmarkId.id.value()
is CLI.Commands.Project.Name -> p.benchmarks.firstOrNull { b -> b.name == benchmarkId.name }?.id?.value()
?: ErrorResponse("Benchmark ${benchmarkId.name} does not exist in this project.").error()
}.map { it to vId }
} }
}.map { pId to it } }
}.bind { (pId, vId) -> }.bind { (bId, vId) ->
Client.callRoute(Routes.Entry.new, EntryRequest( Client.callRoute(Routes.Entry.new, EntryRequest(
vId, Clock.System.now(), pId, vId, Clock.System.now(), bId,
warmupData, measureData, timeUnit, useInput, useHardware warmupData, measureData, timeUnit, useInput, useHardware
)) ))
} }
}.fold({
System.err.println("Failed to add data: ${it.msg}")
exitProcess(-1)
}) {
println("Data added successfully.")
} }
} }
} }
+21 -14
View File
@@ -13,7 +13,10 @@ plugins {
alias(libs.plugins.buildconfig) alias(libs.plugins.buildconfig)
} }
version = "1.0.1" version = rootProject.version as PhoebenchVersion
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("UI Clients version must be at least as high as protocol/common version")
val versionDirectory = layout.buildDirectory.dir("generated/sources/version") val versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val generateVersion = tasks.register<Task>("clientVersion") { val generateVersion = tasks.register<Task>("clientVersion") {
@@ -128,7 +131,7 @@ compose.desktop {
nativeDistributions { nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.jaytux.phoebench.clients" packageName = "com.jaytux.phoebench.clients"
packageVersion = version.toString() packageVersion = (version as PhoebenchVersion).safeString()
jvmArgs += listOf("--enable-native-access=ALL-UNNAMED") jvmArgs += listOf("--enable-native-access=ALL-UNNAMED")
includeAllModules = true includeAllModules = true
@@ -139,19 +142,23 @@ compose.desktop {
} }
} }
fun envValue(key: String): String? { buildConfig {
val fromEnv = providers.environmentVariable(key).orNull generateAtSync = false
if(fromEnv != null) return fromEnv useKotlinOutput { internalVisibility = false }
val envFile = rootProject.file(".env") println("Source sets: ${kotlin.sourceSets.toList().map { it.name }}")
if(!envFile.exists()) return null
return envFile.useLines { lines -> sourceSets.named("desktopMain") {
lines.map { it.trim() }.filter { it.isNotBlank() && !it.startsWith('#') && '=' in it } println("Generating 'desktopMain'")
.map { line -> className("PersistenceConstants")
val (k, v) = line.split('=', limit = 2) packageName("com.jaytux.phoebench.clients")
k.trim() to v.trim().removeSurrounding("\"").removeSurrounding("'")
} envValue("PHOEBENCH_BUILD_RELEASE")?.let {
.firstOrNull { it.first == key }?.second 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.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.dp
import com.jaytux.phoebench.clients.ui.RootView import com.jaytux.phoebench.clients.ui.RootView
@Composable @Composable
@@ -1,9 +1,12 @@
package com.jaytux.phoebench.clients package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import io.ktor.client.HttpClient import io.ktor.client.*
import io.ktor.client.HttpClientConfig import kotlinx.serialization.KSerializer
import kotlinx.serialization.serializer
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
interface IStore { interface IStore {
@@ -27,3 +30,11 @@ expect fun format(f: Float): String
@Composable @Composable
expect fun ServerSelect(server: String, onUpdate: (String) -> Unit) expect fun ServerSelect(server: String, onUpdate: (String) -> Unit)
inline fun <reified T> drag(data: T) = drag(data, serializer<T>())
expect fun <T> drag(data: T, serializer: KSerializer<T>): DragAndDropTransferData
inline fun <reified T> drop(data: DragAndDropEvent): T? = drop(data, serializer<T>())
expect fun <T> drop(data: DragAndDropEvent, serializer: KSerializer<T>): T?
@@ -1,9 +1,15 @@
package com.jaytux.phoebench.clients package com.jaytux.phoebench.clients
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.Either
@@ -18,8 +24,10 @@ import kotlinx.datetime.format.MonthNames
import kotlinx.datetime.format.Padding import kotlinx.datetime.format.Padding
import kotlinx.datetime.format.char import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.Font
import phoebench.clients.compose.generated.resources.*
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.roundToInt import kotlin.math.pow
import kotlin.random.Random import kotlin.random.Random
import kotlin.random.nextInt import kotlin.random.nextInt
import kotlin.time.Clock import kotlin.time.Clock
@@ -85,8 +93,21 @@ inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T
return toMutableList().apply { add(index, elem) } return toMutableList().apply { add(index, elem) }
} }
fun List<Float>.geomean(): Float { inline fun <T, X : Comparable<X>> List<T>.insortIdx(elem: T, crossinline sortBy: (T) -> X): Pair<List<T>, Int?> {
return (size.toFloat() / sumOf { 1.0 / it.toDouble() }).toFloat() val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy)
if(insertionPoint >= 0) return this to null
val index = -insertionPoint - 1
return toMutableList().apply { add(index, elem) } to index
}
inline fun <T> List<T>.replace(crossinline finder: (T) -> Boolean, crossinline update: (T) -> T): List<T> {
val idx = indexOfFirst { finder(it) }
if(idx == -1) return this
val res = ArrayList(this)
res[idx] = update(res[idx])
return res
} }
infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3) infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
@@ -123,3 +144,23 @@ fun <T1, T2, T3, T4> List<Tuple4<T1, T2, T3, T4>>.unzip(): Tuple4<List<T1>, List
return l1 to l2 app l3 app l4 return l1 to l2 app l3 app l4
} }
fun <T> List<T>.removeAt(idx: Int): List<T> = subList(0, idx) + subList(idx + 1, size)
fun List<Float>.geomean(): Float {
var prod = 1.0f
for(value in this) prod *= value
return prod.pow(1.0f / size.toFloat())
}
@Composable
fun TextStyle.makeMonospaced(): TextStyle {
val family = FontFamily(
Font(Res.font.JetBrainsMono_Regular, weight = FontWeight.Normal, style = FontStyle.Normal),
Font(Res.font.JetBrainsMono_Bold, weight = FontWeight.Bold, style = FontStyle.Normal),
Font(Res.font.JetBrainsMono_Italic, weight = FontWeight.Normal, style = FontStyle.Italic),
Font(Res.font.JetBrainsMono_BoldItalic, weight = FontWeight.Bold, style = FontStyle.Italic)
)
return copy(fontFamily = family)
}
@@ -17,6 +17,7 @@ import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.HomeEvent import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.HomeResponse import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.InviteListResponse import com.jaytux.phoebench.common.InviteListResponse
import com.jaytux.phoebench.common.Migrate0To1Request
import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.UserListResponse import com.jaytux.phoebench.common.UserListResponse
import com.jaytux.phoebench.common.value import com.jaytux.phoebench.common.value
@@ -40,11 +41,14 @@ class HomeVM(
private val _users = mutableStateOf(listOf<UserListResponse.UserData>()) private val _users = mutableStateOf(listOf<UserListResponse.UserData>())
private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>()) private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>())
private val _stepsNeeded = mutableStateOf(listOf<HomeResponse.NeededMigration>())
val username = _username.immutable() val username = _username.immutable()
val isAdmin = _isAdmin.immutable() val isAdmin = _isAdmin.immutable()
val projectLimit = _projectLimit.immutable() val projectLimit = _projectLimit.immutable()
val ownProjects = _ownProjects.immutable() val ownProjects = _ownProjects.immutable()
val publicProjects = _publicProjects.immutable() val publicProjects = _publicProjects.immutable()
val stepsNeeded = _stepsNeeded.immutable()
val invites = _invites.immutable() val invites = _invites.immutable()
val users = _users.immutable() val users = _users.immutable()
@@ -81,17 +85,22 @@ class HomeVM(
withScope { withScope {
resetAdmin() resetAdmin()
_repo.getHome().snackOr { _repo.getHome().snackOr {
println("Received home response $it") refreshFrom(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()
} }
} }
} }
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() { fun refreshAdmin() {
if(_isAdmin.value) { if(_isAdmin.value) {
_adminJob = withScope { _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.InviteListResponse
import com.jaytux.phoebench.common.InviteRequest import com.jaytux.phoebench.common.InviteRequest
import com.jaytux.phoebench.common.LogoutRequest import com.jaytux.phoebench.common.LogoutRequest
import com.jaytux.phoebench.common.Migrate0To1Request
import com.jaytux.phoebench.common.ProjectRequest import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.UserListResponse import com.jaytux.phoebench.common.UserListResponse
@@ -31,6 +32,8 @@ interface IHomeRepo {
suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit>
suspend fun migrate0To1(conversions: List<Migrate0To1Request.MigrateProject>): Either<ErrorResponse, HomeResponse>
companion object { companion object {
class Default(private val _client: Client) : IHomeRepo { class Default(private val _client: Client) : IHomeRepo {
override suspend fun getHome(): Either<ErrorResponse, HomeResponse> = override suspend fun getHome(): Either<ErrorResponse, HomeResponse> =
@@ -62,6 +65,9 @@ interface IHomeRepo {
override suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> = override suspend fun newProject(name: String, isPublic: Boolean): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.new, ProjectRequest(name, isPublic)).ignoreValue() _client.callRoute(Routes.Project.new, ProjectRequest(name, isPublic)).ignoreValue()
override suspend fun migrate0To1(conversions: List<Migrate0To1Request.MigrateProject>): Either<ErrorResponse, HomeResponse> =
_client.callRoute(Routes.migrate0To1, Migrate0To1Request(conversions))
} }
fun default(client: Client) = Default(client) fun default(client: Client) = Default(client)
@@ -4,10 +4,13 @@ import androidx.compose.ui.graphics.Color
import com.jaytux.phoebench.clients.Client import com.jaytux.phoebench.clients.Client
import com.jaytux.phoebench.clients.hexString import com.jaytux.phoebench.clients.hexString
import com.jaytux.phoebench.clients.ignoreValue import com.jaytux.phoebench.clients.ignoreValue
import com.jaytux.phoebench.common.BenchmarkRequest
import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.EntryRequest import com.jaytux.phoebench.common.EntryRequest
import com.jaytux.phoebench.common.EntryResponse import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.ErrorResponse import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.PartialBenchmarkRequest
import com.jaytux.phoebench.common.VersionRequest import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.VersionResponse import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.PartialProjectRequest import com.jaytux.phoebench.common.PartialProjectRequest
@@ -23,14 +26,20 @@ interface IProjectRepo {
suspend fun update(name: String? = null, isPublic: Boolean? = null): Either<ErrorResponse, Unit> suspend fun update(name: String? = null, isPublic: Boolean? = null): Either<ErrorResponse, Unit>
suspend fun delete(): Either<ErrorResponse, Unit> suspend fun delete(): Either<ErrorResponse, Unit>
suspend fun newBenchmark(name: String): Either<ErrorResponse, BenchmarkResponse>
suspend fun updateBenchmark(id: Uuid, name: String? = null): Either<ErrorResponse, Unit>
suspend fun deleteBenchmark(id: Uuid): Either<ErrorResponse, Unit>
suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse> suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse>
suspend fun updateVersion(id: Uuid, name: String? = null, color: Color? = null, meta: String? = null): Either<ErrorResponse, Unit> suspend fun updateVersion(id: Uuid, name: String? = null, color: Color? = null, meta: String? = null): Either<ErrorResponse, Unit>
suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit> suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit>
suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>, suspend fun newEntry(benchmark: Uuid, label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
unit: TimeUnit, input: String, hardware: String): Either<ErrorResponse, EntryResponse> unit: TimeUnit, input: String, hardware: String): Either<ErrorResponse, EntryResponse>
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
suspend fun deleteMonitorLogs(): Either<ErrorResponse, Unit>
companion object { companion object {
class Default(private val _client: Client, private val _projectId: Uuid) : IProjectRepo { class Default(private val _client: Client, private val _projectId: Uuid) : IProjectRepo {
override suspend fun get(): Either<ErrorResponse, ProjectResponse> = override suspend fun get(): Either<ErrorResponse, ProjectResponse> =
@@ -42,6 +51,15 @@ interface IProjectRepo {
override suspend fun delete(): Either<ErrorResponse, Unit> = override suspend fun delete(): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.delete, _projectId).ignoreValue() _client.callRoute(Routes.Project.delete, _projectId).ignoreValue()
override suspend fun newBenchmark(name: String): Either<ErrorResponse, BenchmarkResponse> =
_client.callRoute(Routes.Benchmark.new, BenchmarkRequest(name, _projectId))
override suspend fun updateBenchmark(id: Uuid, name: String?): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Benchmark.update, id to PartialBenchmarkRequest(name = name)).ignoreValue()
override suspend fun deleteBenchmark(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Benchmark.delete, id).ignoreValue()
override suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse> = override suspend fun newVersion(name: String, color: Color, meta: String): Either<ErrorResponse, VersionResponse> =
_client.callRoute(Routes.Version.new, VersionRequest(name, color.hexString(), meta, _projectId)) _client.callRoute(Routes.Version.new, VersionRequest(name, color.hexString(), meta, _projectId))
@@ -51,13 +69,16 @@ interface IProjectRepo {
override suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit> = override suspend fun deleteVersion(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Version.delete, id).ignoreValue() _client.callRoute(Routes.Version.delete, id).ignoreValue()
override suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, override suspend fun newEntry(benchmark: Uuid, label: Uuid, timestamp: Instant, warmups: List<Float>,
measurements: List<Float>, unit: TimeUnit, input: String, hardware: String measurements: List<Float>, unit: TimeUnit, input: String, hardware: String
): Either<ErrorResponse, EntryResponse> = ): Either<ErrorResponse, EntryResponse> =
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit, input, hardware)) _client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, benchmark, warmups, measurements, unit, input, hardware))
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> = override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Entry.delete, id).ignoreValue() _client.callRoute(Routes.Entry.delete, id).ignoreValue()
override suspend fun deleteMonitorLogs(): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.rmMonitor, _projectId).ignoreValue()
} }
fun default(client: Client, projectId: Uuid) = Default(client, projectId) fun default(client: Client, projectId: Uuid) = Default(client, projectId)
@@ -7,12 +7,14 @@ import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.HomeEvent import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.ProjectEvent import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.ServerMonitorEvent
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
interface ISSERepo { interface ISSERepo {
suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit> suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit> suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit> suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectMonitor(id: Uuid, onEvent: suspend (Either<ErrorResponse, ServerMonitorEvent>) -> Unit): Either<ErrorResponse, Unit>
companion object { companion object {
class Default(private val _client: Client) : ISSERepo { class Default(private val _client: Client) : ISSERepo {
@@ -24,6 +26,9 @@ interface ISSERepo {
override suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit> = override suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit> =
_client.callSSE(Routes.SSE.projectSpecific, id, onEvent) _client.callSSE(Routes.SSE.projectSpecific, id, onEvent)
override suspend fun connectMonitor(id: Uuid, onEvent: suspend (Either<ErrorResponse, ServerMonitorEvent>) -> Unit): Either<ErrorResponse, Unit> =
_client.callSSE(Routes.SSE.monitor, id, onEvent)
} }
fun default(client: Client) = Default(client) fun default(client: Client) = Default(client)
@@ -1,5 +1,6 @@
package com.jaytux.phoebench.clients.data package com.jaytux.phoebench.clients.data
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -11,11 +12,16 @@ import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
import com.jaytux.phoebench.clients.hexString import com.jaytux.phoebench.clients.hexString
import com.jaytux.phoebench.clients.immutable import com.jaytux.phoebench.clients.immutable
import com.jaytux.phoebench.clients.insort import com.jaytux.phoebench.clients.insort
import com.jaytux.phoebench.clients.insortIdx
import com.jaytux.phoebench.clients.replace
import com.jaytux.phoebench.clients.withScope import com.jaytux.phoebench.clients.withScope
import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.EntryResponse import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.VersionResponse import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.ProjectEvent import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ServerMonitorEvent
import com.jaytux.phoebench.common.TimeUnit import com.jaytux.phoebench.common.TimeUnit
import com.jaytux.phoebench.common.fold
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -57,9 +63,20 @@ class ProjectVM(
data class Entry(val id: Uuid, val label: Version, val timeStamp: Instant, val warmups: List<Float>, data class Entry(val id: Uuid, val label: Version, val timeStamp: Instant, val warmups: List<Float>,
val measurements: List<Float>, val nativeUnit: TimeUnit, val input: String, val hardware: String) { val measurements: List<Float>, val nativeUnit: TimeUnit, val input: String, val hardware: String) {
companion object { companion object {
fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>) = Entry( inline fun fromResponse(it: EntryResponse, map: Map<Uuid, Version>, addInput: (String) -> Unit, addHardware: (String) -> Unit) = Entry(
it.id, map[it.labelId] ?: Version.invalid, it.timestamp, it.id, map[it.versionId] ?: Version.invalid, it.timestamp,
it.warmups, it.measurements, it.unit, it.input, it.hardware it.warmups, it.measurements, it.unit, it.input, it.hardware
).also { addInput(it.input); addHardware(it.hardware) }
}
}
data class Benchmark(val id: Uuid, val name: String, val entries: List<Entry>) {
fun add(entry: Entry) = copy(entries = entries.insort(entry) { it.timeStamp })
fun remove(entry: Uuid) = copy(entries = entries.filter { it.id != entry })
companion object {
inline fun fromResponse(it: BenchmarkResponse, map: Map<Uuid, Version>, addInput: (String) -> Unit, addHardware: (String) -> Unit) = Benchmark(
it.id, it.name, it.entries.map { er -> Entry.fromResponse(er, map, addInput, addHardware) }
) )
} }
} }
@@ -69,20 +86,25 @@ class ProjectVM(
private val _public = mutableStateOf(false) private val _public = mutableStateOf(false)
private val _editable = mutableStateOf(false) private val _editable = mutableStateOf(false)
private val _labels = mutableStateOf(mapOf<Uuid, Version>()) private val _labels = mutableStateOf(mapOf<Uuid, Version>())
private val _entries = mutableStateOf(listOf<Entry>()) private val _benchmarks = mutableStateOf(listOf<Benchmark>())
private val _currentBenchmark = mutableStateOf(0)
private val _inputs = mutableStateOf(setOf<String>()) private val _inputs = mutableStateOf(setOf<String>())
private val _hardware = mutableStateOf(setOf<String>()) private val _hardware = mutableStateOf(setOf<String>())
private val _monitorMessages = mutableStateOf<List<ServerMonitorEvent.ITextEvent>?>(null)
val name = _name.immutable() val name = _name.immutable()
val owner = _owner.immutable() val owner = _owner.immutable()
val public = _public.immutable() val public = _public.immutable()
val editable = _editable.immutable() val editable = _editable.immutable()
val versions = _labels.immutable() val versions = _labels.immutable()
val entries = _entries.immutable() val benchmarks = _benchmarks.immutable()
val currentBenchmark = _currentBenchmark.immutable()
val inputs = _inputs.immutable() val inputs = _inputs.immutable()
val hardware = _hardware.immutable() val hardware = _hardware.immutable()
val monitorMessages = _monitorMessages.immutable()
private var _job: Job? = null private var _job: Job? = null
private var _monitorJob: Job? = null
init { init {
_job = withScope { _job = withScope {
@@ -102,14 +124,10 @@ class ProjectVM(
_owner.value = it.owner.name _owner.value = it.owner.name
_public.value = it.isPublic _public.value = it.isPublic
_editable.value = it.isEditable _editable.value = it.isEditable
_labels.value = it.usedLabels.associate { l -> l.id to Version.fromResponse(l) } _labels.value = it.versions.associate { l -> l.id to Version.fromResponse(l) }
val localInputs = mutableSetOf<String>() val localInputs = mutableSetOf<String>()
val localHardware = mutableSetOf<String>() val localHardware = mutableSetOf<String>()
_entries.value = it.entries.map { e -> _benchmarks.value = it.benchmarks.map { b -> Benchmark.fromResponse(b, _labels.value, localInputs::add, localHardware::add) }
localInputs += e.input
localHardware += e.hardware
Entry.fromResponse(e, _labels.value)
}
_inputs.value = localInputs _inputs.value = localInputs
_hardware.value = localHardware _hardware.value = localHardware
} }
@@ -119,13 +137,15 @@ class ProjectVM(
private fun handleProjectEvent(event: ProjectEvent) { private fun handleProjectEvent(event: ProjectEvent) {
when(event) { when(event) {
ProjectEvent.Deleted -> back() ProjectEvent.Deleted -> back()
is ProjectEvent.EntryDeleted -> _entries.value = _entries.value.filter { it.id != event.id } is ProjectEvent.EntryDeleted -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmarkId }) { it.remove(event.id) }
is ProjectEvent.VersionChanged -> _labels.value += (event.label.id to Version.fromResponse(event.label)) is ProjectEvent.VersionChanged -> _labels.value += (event.label.id to Version.fromResponse(event.label))
is ProjectEvent.VersionDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id } is ProjectEvent.VersionDeleted -> {
_labels.value = _labels.value.filter { it.value.id != event.id }
_benchmarks.value = _benchmarks.value.map { it.copy(entries = it.entries.filter { e -> e.label.id != event.id }) }
}
is ProjectEvent.NewEntry -> { is ProjectEvent.NewEntry -> {
_entries.value = _entries.value.insort(Entry.fromResponse(event.entry, _labels.value), Entry::timeStamp) _benchmarks.value = _benchmarks.value.replace({ it.id == event.entry.benchmarkId }) { it.add(Entry.fromResponse(event.entry, _labels.value,
_inputs.value += event.entry.input { input -> _inputs.value += input }, { hw -> _hardware.value += hw })) }
_hardware.value += event.entry.hardware
} }
is ProjectEvent.NewVersion -> _labels.value += (event.label.id to Version.fromResponse(event.label)) is ProjectEvent.NewVersion -> _labels.value += (event.label.id to Version.fromResponse(event.label))
is ProjectEvent.Updated -> { is ProjectEvent.Updated -> {
@@ -133,6 +153,23 @@ class ProjectVM(
_owner.value = event.changes.owner.name _owner.value = event.changes.owner.name
_public.value = event.changes.isPublic _public.value = event.changes.isPublic
} }
is ProjectEvent.BenchmarkChanged -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmark.id }) { it.copy(name = event.benchmark.name) }
is ProjectEvent.BenchmarkDeleted -> {
val idx = _benchmarks.value.indexOfFirst { it.id == event.id }
if(idx == _currentBenchmark.value) _currentBenchmark.value = 0
else if(idx < _currentBenchmark.value) _currentBenchmark.value -= 1
_benchmarks.value = _benchmarks.value.filter { it.id != event.id }
}
is ProjectEvent.NewBenchmark -> {
val (updated, idx) = _benchmarks.value.insortIdx(
Benchmark.fromResponse(event.benchmark, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw })
) { it.name }
if(idx != null) {
if(idx < _currentBenchmark.value) _currentBenchmark.value += 1
}
_benchmarks.value = updated
}
} }
} }
@@ -183,24 +220,102 @@ class ProjectVM(
} }
} }
fun mkEntry(label: Version, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit, input: String, hardware: String) { fun mkBenchmark(name: String) {
withScope { withScope {
_repo.newEntry(label.id, Clock.System.now(), warmups, measurements, unit, input, hardware).snackOr { _repo.newBenchmark(name).snackOr { bench ->
if(_job == null) { if(_job == null) {
_entries.value += Entry.fromResponse(it, _labels.value) val (updated, idx) = _benchmarks.value.insortIdx(
_inputs.value += input Benchmark.fromResponse(bench, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw })
_hardware.value += hardware ) { it.name }
_currentBenchmark.value.let { curr ->
if(idx != null) {
if(idx < curr) _currentBenchmark.value = curr + 1
}
}
_benchmarks.value = updated
} }
} }
} }
} }
fun deleteEntry(id: Uuid) { fun updateBenchmark(id: Uuid, name: String?) {
withScope { withScope {
_repo.deleteEntry(id).snackOr { val old = _benchmarks.value.find { it.id == id } ?: return@withScope
if(_job == null) _repo.updateBenchmark(id, name).snackOr {
_entries.value = _entries.value.filter { it.id != id } if(_job == null) {
_benchmarks.value = _benchmarks.value.replace({ it.id == id }) { it.copy(name = name ?: old.name) }
}
} }
} }
} }
fun deleteBenchmark(id: Uuid) {
withScope {
_repo.deleteBenchmark(id).snackOr {
if(_job == null) {
val idx = _benchmarks.value.indexOfFirst { it.id == id }
if(idx == _currentBenchmark.value) _currentBenchmark.value = 0
else if(idx < _currentBenchmark.value) _currentBenchmark.value -= 1
_benchmarks.value = _benchmarks.value.filter { it.id != id }
}
}
}
}
fun mkEntry(label: Version, warmups: List<Float>, measurements: List<Float>, unit: TimeUnit, input: String, hardware: String) {
withScope {
val current = _benchmarks.value.getOrNull(_currentBenchmark.value) ?: return@withScope
_repo.newEntry(current.id, label.id, Clock.System.now(), warmups, measurements, unit, input, hardware).snackOr { entry ->
if(_job == null) {
_benchmarks.value = _benchmarks.value.replace({ it.id == entry.benchmarkId }) { it.add(Entry.fromResponse(entry, _labels.value,
{ input -> _inputs.value += input }, { hw -> _hardware.value += hw })) }
}
}
}
}
fun previousBenchmark() {
_currentBenchmark.value = maxOf(0, minOf(_currentBenchmark.value - 1, _benchmarks.value.size - 1))
}
fun nextBenchmark() {
_currentBenchmark.value = maxOf(0, minOf(_currentBenchmark.value + 1, _benchmarks.value.size - 1))
}
fun selectBenchmark(id: Uuid) {
_currentBenchmark.value = maxOf(_benchmarks.value.indexOfFirst { it.id == id }, 0)
}
fun openMonitor() {
if(_monitorJob != null) return
_monitorMessages.value = listOf()
_monitorJob = withScope {
_sseRepo.connectMonitor(_id) { ev ->
ev.snackOr {
when(it) {
is ServerMonitorEvent.Backlog -> {
val res = ArrayList<ServerMonitorEvent.ITextEvent>(it.messages.size + 2)
it.start?.let { start -> res += start }
res.addAll(it.messages)
it.end?.let { end -> res += end }
_monitorMessages.value = res
}
ServerMonitorEvent.Cleared -> _monitorMessages.value = listOf()
is ServerMonitorEvent.ApplicationEnd, is ServerMonitorEvent.ApplicationStart, is ServerMonitorEvent.Message ->
_monitorMessages.value = (_monitorMessages.value ?: listOf()) + it
}
}
}
}
}
fun closeMonitor() {
_monitorJob?.cancel()
_monitorJob = null
_monitorMessages.value = null
}
fun clearMonitor() = withScope {
_repo.deleteMonitorLogs().snackOnError()
}
} }
@@ -1,5 +1,24 @@
package com.jaytux.phoebench.clients.theme package com.jaytux.phoebench.clients.theme
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
val linkColor = Color(0xFF64B5F6) val linkColor = Color(0xFF64B5F6)
enum class UiMode(val minWidth: Dp) {
MOBILE(0.dp),
NARROW(0.dp),
WIDE(2000.dp);
companion object {
fun fromDp(width: Dp) = when {
width < NARROW.minWidth -> MOBILE
width < WIDE.minWidth -> NARROW
else -> WIDE
}
}
}
val GlobalUiMode = compositionLocalOf { UiMode.MOBILE }
@@ -25,6 +25,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -237,10 +238,12 @@ fun SignupView(server: String?, error: String?, onSwitchLogin: () -> Unit, onSig
@Composable @Composable
fun AuthenticatedRoot() { fun AuthenticatedRoot() {
val snacks = SnackProvider.get() val snacks = SnackProvider.get()
val auth = AuthProvider.get()
val state = remember { SnackbarHostState() } val state = remember { SnackbarHostState() }
val vm = viewModel { HomeVM() } val vm = viewModel { HomeVM() }
var loggingOut by remember { mutableStateOf(false) } var loggingOut by remember { mutableStateOf(false) }
var currentProject by remember { mutableStateOf<Uuid?>(null) } var currentProject by remember { mutableStateOf<Uuid?>(null) }
val server by auth.server
LaunchedEffect(snacks) { LaunchedEffect(snacks) {
snacks.snacks.collect { state.showSnackbar(it) } snacks.snacks.collect { state.showSnackbar(it) }
@@ -254,7 +257,12 @@ fun AuthenticatedRoot() {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("PhoeBench v${ClientVersion.VERSION}", style = MaterialTheme.typography.headlineLarge) }, title = {
Column {
Text("PhoeBench v${ClientVersion.VERSION}", style = MaterialTheme.typography.headlineLarge)
server?.let { Text("Connected to $it", style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(0.6f)) }
}
},
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
@@ -320,7 +328,19 @@ fun ConfirmLogoutDialog(onCancel: () -> Unit, onLogout: (everywhere: Boolean) ->
@Composable @Composable
fun HomeView(onSelectProject: (Uuid) -> Unit) { fun HomeView(onSelectProject: (Uuid) -> Unit) {
val vm = viewModel { HomeVM() } val vm = viewModel { HomeVM() }
val todo by vm.stepsNeeded
todo.firstOrNull()?.let {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (it) {
is HomeResponse.NeededMigration.Migrate0To1 -> Migrate0To1View(vm, it)
}
}
} ?: HomePanel(vm, onSelectProject)
}
@Composable
fun HomePanel(vm: HomeVM, onSelectProject: (Uuid) -> Unit) {
val auth = AuthProvider.get() val auth = AuthProvider.get()
val username by vm.username val username by vm.username
val isAdmin by vm.isAdmin val isAdmin by vm.isAdmin
@@ -0,0 +1,241 @@
package com.jaytux.phoebench.clients.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.draganddrop.dragAndDropSource
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.composables.icons.lucide.Cross
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.X
import com.jaytux.phoebench.clients.app
import com.jaytux.phoebench.clients.data.HomeVM
import com.jaytux.phoebench.clients.drag
import com.jaytux.phoebench.clients.drop
import com.jaytux.phoebench.clients.removeAt
import com.jaytux.phoebench.clients.replace
import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.Migrate0To1Request
@Composable
fun Migrate0To1View(vm: HomeVM, input: HomeResponse.NeededMigration.Migrate0To1) {
var isAdding by remember { mutableStateOf(false) }
var projectsToDo by remember { mutableStateOf(input.projects) }
var remapped by remember { mutableStateOf(listOf<Triple<String, Boolean, List<HomeResponse.NeededMigration.Migrate0To1.OldProject>>>()) }
Surface(Modifier.fillMaxSize(0.85f), color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 10.dp, shape = MaterialTheme.shapes.large) {
Column(Modifier.padding(15.dp)) {
Text("Data Migration", style = MaterialTheme.typography.headlineMedium)
Text("The server you connected to has been updated, requiring some decisions from you.")
Text("This new version allows you to organise different benchmarks together in one project. That way, they will share installed versions.")
Text("Please create the new projects you would like to use, and drag the existing projects onto them as benchmarks. You can also drag a benchmark onto an empty space to have it become a top-level project containing a benchmark with the same name.")
Spacer(Modifier.height(10.dp))
Row(Modifier.weight(1f)) {
var showTargets by remember { mutableStateOf(false) }
val globalTarget = remember {
object : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) {
showTargets = true
super.onStarted(event)
}
override fun onEnded(event: DragAndDropEvent) {
showTargets = false
super.onEnded(event)
}
override fun onDrop(event: DragAndDropEvent): Boolean {
val got = drop<HomeResponse.NeededMigration.Migrate0To1.OldProject>(event) ?: return false
println("DROP ONTO global")
projectsToDo = projectsToDo.filter { it.id != got.id }
remapped += got.name to false app listOf(got)
return true
}
}
}
val startMod = if(showTargets) Modifier.border(BorderStroke(3.dp, MaterialTheme.colorScheme.onSurface)) else Modifier
Surface(startMod.weight(0.66f).fillMaxHeight().dragAndDropTarget({ true }, globalTarget), tonalElevation = 10.dp, shape = MaterialTheme.shapes.large) {
Column(Modifier.padding(10.dp)) {
Text("New projects", style = MaterialTheme.typography.headlineSmall)
LazyVerticalGrid(GridCells.Adaptive(minSize = 350.dp), Modifier.weight(1f)) {
itemsIndexed(remapped) { idx, (name, public, old) ->
val target = remember {
object : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) {
showTargets = true
super.onStarted(event)
}
override fun onEnded(event: DragAndDropEvent) {
showTargets = false
super.onEnded(event)
}
override fun onDrop(event: DragAndDropEvent): Boolean {
val got = drop<HomeResponse.NeededMigration.Migrate0To1.OldProject>(event) ?: return false
println("DROP ONTO $name")
projectsToDo = projectsToDo.filter { it.id != got.id }
remapped = remapped.replace({ it.first == name }) { it.first to it.second app (it.third + got) }
return true
}
}
}
val toggle = { public: Boolean ->
remapped = remapped.replace({ it.first == name }) { it.first to public app it.third }
}
Surface(startMod.padding(5.dp).dragAndDropTarget({ true }, target), color = MaterialTheme.colorScheme.tertiaryContainer, tonalElevation = 4.dp, shadowElevation = 1.dp, shape = MaterialTheme.shapes.medium) {
Row(Modifier.padding(5.dp).width(IntrinsicSize.Min)) {
Column(Modifier.weight(1f)) {
Text(name)
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(!public, { toggle(false) }, SegmentedButtonDefaults.itemShape(0, 2)) { Text("Private") }
SegmentedButton(public, { toggle(true) }, SegmentedButtonDefaults.itemShape(1, 2)) { Text("Public") }
}
Column(Modifier.padding(start = 5.dp)) {
old.forEach { Text(it.name, style = MaterialTheme.typography.bodySmall) }
}
}
IconButton({ projectsToDo += old; remapped = remapped.removeAt(idx) }) {
Icon(Lucide.X, "Remove")
}
}
}
}
if(remapped.isNotEmpty()) {
item {
Surface(startMod.padding(5.dp).dragAndDropTarget({ true }, globalTarget), color = MaterialTheme.colorScheme.tertiaryContainer, tonalElevation = 4.dp, shadowElevation = 1.dp, shape = MaterialTheme.shapes.medium) {
Text("Create new...", Modifier.padding(vertical = 15.dp), fontStyle = FontStyle.Italic, color = LocalContentColor.current.copy(alpha = 0.66f))
}
}
}
}
Spacer(Modifier.height(10.dp))
Row {
Box(Modifier.weight(1f))
IconButton({ isAdding = true }) {
Icon(Lucide.Plus, "Add project")
}
}
}
}
Spacer(Modifier.width(10.dp))
Surface(Modifier.weight(0.33f).fillMaxHeight(), tonalElevation = 10.dp, shape = MaterialTheme.shapes.large) {
LazyColumn(Modifier.padding(10.dp).fillMaxHeight()) {
stickyHeader {
Text("Existing projects", style = MaterialTheme.typography.headlineSmall)
}
items(projectsToDo) {
Column {
Surface(Modifier.padding(5.dp).fillMaxWidth().dragAndDropSource { _ -> drag(it) }, tonalElevation = 15.dp, shape = MaterialTheme.shapes.medium) {
Text("${it.name} (${it.versions.size} versions)", Modifier.padding(10.dp))
}
Spacer(Modifier.height(10.dp))
}
}
}
}
}
Spacer(Modifier.height(10.dp))
Row {
Box(Modifier.weight(1f))
Button({ vm.migrate(remapped.map { (name, public, old) -> Migrate0To1Request.MigrateProject(name, public, old.map { it.id }) }) },
enabled = projectsToDo.isEmpty()) {
Text("Perform migration")
}
}
}
}
if(isAdding) AddProjectDialog({ isAdding = false }) { name, public ->
remapped += name to public app listOf()
isAdding = false
}
}
@Composable
fun AddProjectDialog(onCancel: () -> Unit, onAdd: (name: String, public: Boolean) -> Unit) {
Dialog(onDismissRequest = onCancel) {
var name by remember { mutableStateOf("") }
var public by remember { mutableStateOf(false) }
Surface(Modifier.padding(15.dp).widthIn(400.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).width(IntrinsicSize.Min)) {
Text("Create new project", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
Spacer(Modifier.height(10.dp))
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(!public, { public = false }, SegmentedButtonDefaults.itemShape(0, 2)) { Text("Private") }
SegmentedButton(public, { public = true }, SegmentedButtonDefaults.itemShape(1, 2)) { Text("Public") }
}
Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, {
onAdd(name.trim(), public)
}, confirmText = "Create", canConfirm = name.trim().isNotBlank())
}
}
}
}
@@ -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 package com.jaytux.phoebench.clients
import androidx.compose.ui.window.Window import androidx.compose.foundation.layout.Column
import androidx.compose.ui.window.application import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.SwingPanel
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import com.jaytux.phoebench.clients.theme.GlobalUiMode
import com.jaytux.phoebench.clients.theme.UiMode
import java.awt.GraphicsConfiguration
import java.awt.GraphicsEnvironment
import java.awt.Toolkit
import java.awt.event.ComponentEvent
import java.awt.event.ComponentListener
import javax.swing.JPanel
fun windowData(config: GraphicsConfiguration): Pair<DpSize, DpOffset> {
val bounds = config.bounds
val insets = Toolkit.getDefaultToolkit().getScreenInsets(config)
val availableWidthPx = bounds.width - insets.left - insets.right
val availableHeightPx = bounds.height - insets.top - insets.bottom
val density = config.defaultTransform.scaleX.toFloat()
val size = DpSize((availableWidthPx / density).dp, (availableHeightPx / density).dp)
val offset = DpOffset((insets.left / density).dp, (insets.top / density).dp)
return size to offset
}
fun main() = application { fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "PhoeBench") { val (winSize, winOffset) = windowData(GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration)
App() 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.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draganddrop.*
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import com.github.javakeyring.Keyring import com.github.javakeyring.Keyring
import com.jaytux.phoebench.clients.ui.DefaultServerSelect import com.jaytux.phoebench.clients.ui.DefaultServerSelect
@@ -10,12 +11,13 @@ import io.ktor.client.engine.okhttp.*
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection import java.awt.datatransfer.StringSelection
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
object JVMStore : IStore { object JVMStore : IStore {
private val json = Json private val json = Json
const val SERVICE = "com.jaytux.phoebench" const val SERVICE = PersistenceConstants.SERVICE
private class StoredProperty<T>( private class StoredProperty<T>(
val key: String, val key: String,
@@ -59,7 +61,20 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
actual suspend fun String.toClipEntry(): ClipEntry = actual suspend fun String.toClipEntry(): ClipEntry =
ClipEntry(StringSelection(this)) ClipEntry(StringSelection(this))
actual fun format(f: Float): String = String.format("%.03f", f) actual fun format(f: Float): String = String.format("%.03f", f).let { it.substring(0, minOf(it.length, 5)) }
@Composable @Composable
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate) actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate)
@OptIn(ExperimentalComposeUiApi::class)
actual fun <T> drag(data: T, serializer: KSerializer<T>): DragAndDropTransferData =
DragAndDropTransferData(
DragAndDropTransferable(StringSelection(Json.encodeToString(serializer, data))),
supportedActions = listOf(DragAndDropTransferAction.Move)
)
@OptIn(ExperimentalComposeUiApi::class)
actual fun <T> drop(data: DragAndDropEvent, serializer: KSerializer<T>): T? =
if(data.awtTransferable.isDataFlavorSupported(DataFlavor.stringFlavor))
Json.decodeFromString(serializer, data.awtTransferable.getTransferData(DataFlavor.stringFlavor) as String)
else null
@@ -1,14 +1,30 @@
package com.jaytux.phoebench.clients package com.jaytux.phoebench.clients
import androidx.compose.runtime.CompositionLocal
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.ComposeViewport import androidx.compose.ui.window.ComposeViewport
import com.jaytux.phoebench.clients.theme.GlobalUiMode
import com.jaytux.phoebench.clients.theme.UiMode
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.browser.window
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
suspend fun main() { suspend fun main() {
config = loadConfig() config = loadConfig()
ComposeViewport(document.body!!) { 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 package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.draganddrop.domDataTransferOrNull
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import com.jaytux.phoebench.clients.ui.constantServerSelect import com.jaytux.phoebench.clients.ui.constantServerSelect
import io.ktor.client.* import io.ktor.client.*
@@ -9,9 +13,9 @@ import kotlinx.browser.window
import kotlinx.coroutines.await import kotlinx.coroutines.await
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import org.w3c.dom.DataTransfer
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
object WasmJsStore : IStore { object WasmJsStore : IStore {
@@ -57,7 +61,7 @@ actual suspend fun String.toClipEntry(): ClipEntry =
@JsFun("(num) => num.toFixed(3)") @JsFun("(num) => num.toFixed(3)")
private external fun jsFormat(num: Double): String private external fun jsFormat(num: Double): String
actual fun format(f: Float): String = jsFormat(f.toDouble()) actual fun format(f: Float): String = jsFormat(f.toDouble()).let { it.substring(0, minOf(it.length, 5)) }
@Serializable @Serializable
data class Config(val server: String) data class Config(val server: String)
@@ -75,3 +79,18 @@ lateinit var config: Config
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) { actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) {
constantServerSelect(config.server)(server, onUpdate) constantServerSelect(config.server)(server, onUpdate)
} }
@OptIn(ExperimentalWasmJsInterop::class)
@JsFun("() => new DataTransfer()")
private external fun createDataTransfer(): DataTransfer
@OptIn(ExperimentalComposeUiApi::class)
actual fun <T> drag(data: T, serializer: KSerializer<T>): DragAndDropTransferData = DragAndDropTransferData(
createDataTransfer().also { it.setData("application/json", Json.encodeToString(serializer, data)) }
)
@OptIn(ExperimentalComposeUiApi::class)
actual fun <T> drop(data: DragAndDropEvent, serializer: KSerializer<T>): T? =
data.transferData?.domDataTransferOrNull?.getData("application/json")?.let {
Json.decodeFromString(serializer, it)
}
@@ -1,10 +1,14 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PhoeBench</title> <title>PhoeBench</title>
<link type="text/css" rel="stylesheet" href="styles.css"> <link type="text/css" rel="stylesheet" href="styles.css" />
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-Regular.ttf" />
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-Bold.ttf" />
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-Italic.ttf" />
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-BoldItalic.ttf" />
<script type="application/javascript" src="composeApp.js"></script> <script type="application/javascript" src="composeApp.js"></script>
</head> </head>
<body> <body>
+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 versionDirectory = layout.buildDirectory.dir("generated/sources/version")
val requestsDirectory = layout.projectDirectory.dir("src/commonMain/kotlin/com/jaytux/phoebench/common/") val requestsDirectory = layout.projectDirectory.dir("src/commonMain/kotlin/com/jaytux/phoebench/common/")
version = rootProject.version as PhoebenchVersion
val generatePartials = tasks.register<Exec>("generatePartials") { val generatePartials = tasks.register<Exec>("generatePartials") {
group = "generation" group = "generation"
description = "Generate Partial classes (requests with all-nullable fields)" description = "Generate Partial classes (requests with all-nullable fields)"
val scriptFile = project.file("partialize.main.kts") val scriptFile = project.file("partialize.main.kts")
val targets = fileTree(requestsDirectory) { val targets = fileTree(requestsDirectory) {
include("**/*.kt") include("Requests.kt")
} }
val lst = targets.map { it.absolutePath } val lst = targets.map { it.absolutePath }
@@ -37,7 +39,7 @@ val generateVersion = tasks.register<Task>("protocolVersion") {
package com.jaytux.phoebench.common package com.jaytux.phoebench.common
object ProtocolVersion { object ProtocolVersion {
const val VERSION="${rootProject.version}" const val VERSION="${version}"
} }
""".trimIndent()) """.trimIndent())
} }
@@ -0,0 +1,62 @@
package com.jaytux.phoebench.common
import io.ktor.client.plugins.websocket.*
import io.ktor.http.*
import io.ktor.util.reflect.*
import io.ktor.utils.io.*
import io.ktor.websocket.*
import kotlin.uuid.Uuid
sealed class CSERoute<TParams, TEvent: Any>(val path: String, val elevation: Elevation, private val _eventType: TypeInfo) {
open val pattern = path
protected open fun buildUrl(params: TParams): String = path
abstract fun extractParams(reqParams: Parameters): TParams?
suspend fun call(client: IClient, params: TParams, body: suspend (sender: suspend (TEvent) -> Unit) -> Unit): Either<ErrorResponse, Unit> {
val server = client.serverUrl.replace("https", "ws").replace("http", "ws")
var error: ErrorResponse? = null
try {
client.client.webSocket("$server${buildUrl(params)}", {}) {
try {
body { sendSerialized(it, _eventType) }
}
catch(e: CancellationException) {
closeReason.await()?.let {
if(it.code != CloseReason.Codes.NORMAL.code) error = ErrorResponse(it.message)
}
throw e
}
}
return Unit.value()
}
catch(e: CancellationException) {
return (error ?: ErrorResponse(e.message ?: "Unknown websocket error")).error()
}
catch(e: WebSocketException) {
return ErrorResponse("Could not set up websocket stream: ${e.message}").error()
}
catch(e: Exception) {
return ErrorResponse("WebSocket connection failed: ${e.message}").error()
}
}
class CSERoute1<T1, TEvent: Any>(path: String, elevation: Elevation, eventType: TypeInfo, val urlEncode: (T1) -> String, val urlDecode: (String?) -> T1?)
: CSERoute<T1, TEvent>(path, elevation, eventType)
{
override val pattern: String = "$path/{param}"
override fun buildUrl(params: T1): String = "$path/${urlEncode(params)}"
override fun extractParams(reqParams: Parameters): T1? = urlDecode(reqParams["param"])
}
companion object {
inline fun <T1, reified TEvent: Any> single(path: String, elevation: Elevation,
noinline urlEncode: (T1) -> String = { it.toString() }, noinline urlDecode: (String?) -> T1?
) = CSERoute1<T1, TEvent>(path, elevation, typeInfo<TEvent>(), urlEncode, urlDecode)
inline fun <reified TEvent: Any> uuid(path: String, elevation: Elevation) = single<Uuid, TEvent>(path, elevation) {
it?.let { p -> Uuid.parseOrNull(p) }
}
}
}
@@ -0,0 +1,8 @@
package com.jaytux.phoebench.common
enum class CloseReasons(val code: Short) {
NOT_AUTHORIZED(4001),
INVALID_REQUEST(4002),
CONFLICT(4003),
NOT_FOUND(4004)
}
@@ -1,6 +1,8 @@
package com.jaytux.phoebench.common package com.jaytux.phoebench.common
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.collections.fold
import kotlin.time.Instant
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@Serializable @Serializable
@@ -42,6 +44,15 @@ sealed class ProjectEvent {
@Serializable @Serializable
data class Updated(val changes: HomeResponse.ProjectSummary) : ProjectEvent() data class Updated(val changes: HomeResponse.ProjectSummary) : ProjectEvent()
@Serializable
data class NewBenchmark(val benchmark: BenchmarkResponse) : ProjectEvent()
@Serializable
data class BenchmarkChanged(val benchmark: BenchmarkSummary) : ProjectEvent()
@Serializable
data class BenchmarkDeleted(val id: Uuid) : ProjectEvent()
@Serializable @Serializable
data class NewVersion(val label: VersionResponse) : ProjectEvent() data class NewVersion(val label: VersionResponse) : ProjectEvent()
@@ -55,5 +66,82 @@ sealed class ProjectEvent {
data class NewEntry(val entry: EntryResponse) : ProjectEvent() data class NewEntry(val entry: EntryResponse) : ProjectEvent()
@Serializable @Serializable
data class EntryDeleted(val id: Uuid) : ProjectEvent() data class EntryDeleted(val id: Uuid, val benchmarkId: Uuid) : ProjectEvent()
}
@Serializable
enum class Stream {
STDOUT, STDERR
}
@Serializable
enum class ANSI(val bitIdx: Int, val ansiCode: Int) {
BOLD(0, 1), UNDERLINE(1, 4),
FG_RED(2, 31), FG_GREEN(3, 32), FG_YELLOW(4, 33),
FG_BLUE(5, 34), FG_GRAY(6, 90),
BG_RED(7, 41), BG_GREEN(8, 42), BG_YELLOW(9, 43),
BG_BLUE(10, 44), BG_GRAY(11, 100);
companion object {
private val _mapping: Map<Int, ANSI>
val ansiMapping: Map<Int, ANSI>
init {
if(entries.map { it.bitIdx }.toSet().size != entries.size)
throw IllegalStateException("ANSI bit indices contain duplicates")
if(entries.map { it.ansiCode }.toSet().size != entries.size)
throw IllegalStateException("ANSI escape codes contain duplicates")
_mapping = entries.associateBy { ansi -> ansi.bitIdx }
ansiMapping = entries.associateBy { ansi -> ansi.ansiCode }
}
fun pack(vararg options: ANSI): UShort = options.fold(0u) { acc, ansi ->
acc or (1u shl ansi.bitIdx).toUShort()
}
fun unpack(packed: UShort): List<ANSI> {
val res = mutableListOf<ANSI>()
var remaining = packed
for(i in 0..15) {
if((remaining and 1u) != 0.toUShort()) {
_mapping[i]?.let { res += it }
}
remaining = (remaining.toUInt() shr 1).toUShort()
}
return res
}
}
}
@Serializable
sealed class ClientMonitorEvent {
@Serializable
data class Message(val msg: String, val stream: Stream, val options: UShort) : ClientMonitorEvent() {
constructor(msg: String, stream: Stream, vararg options: ANSI) : this(msg, stream, ANSI.pack(*options))
constructor(msg: String, stream: Stream, options: List<ANSI>) : this(msg, stream, ANSI.pack(*options.toTypedArray()))
}
@Serializable
data class ApplicationFinished(val exitCode: Int) : ClientMonitorEvent()
}
@Serializable
sealed class ServerMonitorEvent {
sealed interface ITextEvent
@Serializable
object Cleared : ServerMonitorEvent()
@Serializable
data class ApplicationStart(val time: Instant) : ServerMonitorEvent(), ITextEvent
@Serializable
data class Message(val timeStamp: Instant, val message: String, val options: UShort, val stream: Stream) : ServerMonitorEvent(), ITextEvent
@Serializable
data class Backlog(val start: ApplicationStart?, val messages: List<Message>, val end: ApplicationEnd?) : ServerMonitorEvent()
@Serializable
data class ApplicationEnd(val time: Instant, val exitCode: Int) : ServerMonitorEvent(), ITextEvent
} }
@@ -19,13 +19,16 @@ class EmptyRequest
data class RefreshRequest(val refreshToken: Uuid) data class RefreshRequest(val refreshToken: Uuid)
@Serializable @ToPartialize @Serializable @ToPartialize
data class ProjectRequest(val name: String, var isPublic: Boolean) data class ProjectRequest(val name: String, val isPublic: Boolean)
@Serializable @ToPartialize @Serializable @ToPartialize
data class VersionRequest(val name: String, val color: String, val meta: String, val projectId: Uuid) data class BenchmarkRequest(val name: String, val projectId: Uuid)
@Serializable @ToPartialize @Serializable @ToPartialize
data class EntryRequest(val version: Uuid, val timestamp: Instant, val projectId: Uuid, val warmups: List<Float>, data class VersionRequest(val name: String, val color: String, val meta: String, val benchmarkId: Uuid)
@Serializable @ToPartialize
data class EntryRequest(val version: Uuid, val timestamp: Instant, val benchmarkId: Uuid, val warmups: List<Float>,
val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String) val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
@Serializable @Serializable
@@ -36,3 +39,9 @@ data class UserUpdateRequest(val projectLimit: Int? = null, val isAdmin: Boolean
@Serializable @Serializable
data class InviteRequest(val asAdmin: Boolean) data class InviteRequest(val asAdmin: Boolean)
@Serializable
data class Migrate0To1Request(val conversions: List<MigrateProject>) {
@Serializable
data class MigrateProject(val newProjectName: String, val isPublic: Boolean, val oldProjectsAsBenchmarks: List<Uuid>)
}
@@ -32,20 +32,35 @@ data class UserListResponse(val users: List<UserData>) {
data class NamedID(val name: String, val id: Uuid) data class NamedID(val name: String, val id: Uuid)
@Serializable @Serializable
data class HomeResponse(val username: String, val isAdmin: Boolean, val projectLimit: Int, val ownProjects: List<ProjectSummary>, val publicProjects: List<ProjectSummary>) { data class HomeResponse(val username: String, val isAdmin: Boolean, val projectLimit: Int, val ownProjects: List<ProjectSummary>, val publicProjects: List<ProjectSummary>, val neededMigrations: List<NeededMigration>) {
@Serializable @Serializable
data class ProjectSummary(val id: Uuid, val name: String, val isPublic: Boolean, val owner: NamedID) data class ProjectSummary(val id: Uuid, val name: String, val isPublic: Boolean, val owner: NamedID)
@Serializable
sealed class NeededMigration {
@Serializable
data class Migrate0To1(val projects: List<OldProject>) : NeededMigration() {
@Serializable
data class OldProject(val id: Uuid, val name: String, val versions: List<VersionResponse>)
}
}
} }
@Serializable @Serializable
data class ProjectResponse(val id: Uuid, val name: String, val owner: NamedID, val isPublic: Boolean, val isEditable: Boolean, data class ProjectResponse(val id: Uuid, val name: String, val owner: NamedID, val isPublic: Boolean, val isEditable: Boolean,
val usedLabels: List<VersionResponse>, val entries: List<EntryResponse>) val benchmarks: List<BenchmarkResponse>, val versions: List<VersionResponse>)
@Serializable
data class BenchmarkSummary(val id: Uuid, val name: String)
@Serializable
data class BenchmarkResponse(val id: Uuid, val name: String, val entries: List<EntryResponse>)
@Serializable @Serializable
data class VersionResponse(val id: Uuid, val name: String, val color: String, val meta: String) data class VersionResponse(val id: Uuid, val name: String, val color: String, val meta: String)
@Serializable @Serializable
data class EntryResponse(val id: Uuid, val labelId: Uuid, val timestamp: Instant, val warmups: List<Float>, data class EntryResponse(val id: Uuid, val benchmarkId: Uuid, val versionId: Uuid, val timestamp: Instant, val warmups: List<Float>,
val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String) val measurements: List<Float>, val unit: TimeUnit, val input: String, val hardware: String)
@Serializable @Serializable
@@ -22,12 +22,20 @@ object Routes {
val handshake = ApiRoute.get<HandshakeResponse>("/", Elevation.UN_AUTH) val handshake = ApiRoute.get<HandshakeResponse>("/", Elevation.UN_AUTH)
val home = ApiRoute.get<HomeResponse>("/home", Elevation.AUTH) val home = ApiRoute.get<HomeResponse>("/home", Elevation.AUTH)
val migrate0To1 = ApiRoute.post<Migrate0To1Request, HomeResponse>("/migrate/0-1", Elevation.AUTH)
object Project { object Project {
val new = ApiRoute.post<ProjectRequest, ProjectResponse>("/project", Elevation.AUTH) val new = ApiRoute.post<ProjectRequest, ProjectResponse>("/project", Elevation.AUTH)
val get = ApiRoute.getUuid<ProjectResponse>("/project", Elevation.AUTH) val get = ApiRoute.getUuid<ProjectResponse>("/project", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialProjectRequest>("/project", Elevation.AUTH) val update = ApiRoute.patchUuidNoRes<PartialProjectRequest>("/project", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH) val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH)
val rmMonitor = ApiRoute.deleteUuidNoRes("/project/monitor", Elevation.AUTH)
}
object Benchmark {
val new = ApiRoute.post<BenchmarkRequest, BenchmarkResponse>("/benchmark", Elevation.AUTH)
val update = ApiRoute.patchUuidNoRes<PartialBenchmarkRequest>("/benchmark", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/benchmark", Elevation.AUTH)
} }
object Version { object Version {
@@ -45,5 +53,10 @@ object Routes {
val home = SSERoute.noArgs<HomeEvent>("/rt/home", Elevation.AUTH) val home = SSERoute.noArgs<HomeEvent>("/rt/home", Elevation.AUTH)
val admin = SSERoute.noArgs<AdminEvent>("/rt/admin", Elevation.ADMIN) val admin = SSERoute.noArgs<AdminEvent>("/rt/admin", Elevation.ADMIN)
val projectSpecific = SSERoute.uuid<ProjectEvent>("/rt/project", Elevation.AUTH) val projectSpecific = SSERoute.uuid<ProjectEvent>("/rt/project", Elevation.AUTH)
val monitor = SSERoute.uuid<ServerMonitorEvent>("/rt/monitor", Elevation.AUTH)
}
object CSE {
val monitor = CSERoute.uuid<ClientMonitorEvent>("/stream/monitor", Elevation.AUTH)
} }
} }
+4
View File
@@ -23,6 +23,7 @@ kolor-picker = "2.1.0"
shadow = "9.3.0" shadow = "9.3.0"
clikt = "5.0.3" clikt = "5.0.3"
buildconfig = "6.0.10" buildconfig = "6.0.10"
process = "1.5.1"
[libraries] [libraries]
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
@@ -50,6 +51,7 @@ ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negoti
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-websocket = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" } ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" }
@@ -64,6 +66,7 @@ ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref =
ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" } ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" }
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" } ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" }
ktor-server-websocket = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
json = { module = "org.json:json", version.ref = "json" } json = { module = "org.json:json", version.ref = "json" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
@@ -87,6 +90,7 @@ kolor = { module = "com.kborowy:kolor-picker", version.ref = "kolor-picker" }
java-keystore = { module = "com.github.javakeyring:java-keyring", version.ref = "java-keystore" } java-keystore = { module = "com.github.javakeyring:java-keyring", version.ref = "java-keystore" }
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
process = { module = "com.github.pgreze:kotlin-process", version.ref = "process" }
[plugins] [plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
+5 -1
View File
@@ -7,7 +7,10 @@ plugins {
} }
group = "com.jaytux.phoebench" group = "com.jaytux.phoebench"
version = rootProject.version version = rootProject.version as PhoebenchVersion
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("Server version must be at least as high as protocol/common version")
repositories { repositories {
mavenCentral() mavenCentral()
@@ -53,6 +56,7 @@ dependencies {
implementation(libs.ktor.server.cors) implementation(libs.ktor.server.cors)
implementation(libs.ktor.server.status.pages) implementation(libs.ktor.server.status.pages)
implementation(libs.ktor.server.sse) implementation(libs.ktor.server.sse)
implementation(libs.ktor.server.websocket)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
@@ -3,6 +3,8 @@ package com.jaytux.phoebench.server
import com.jaytux.phoebench.common.AdminEvent import com.jaytux.phoebench.common.AdminEvent
import com.jaytux.phoebench.common.HomeEvent import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.ProjectEvent import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ServerMonitorEvent
import io.ktor.client.plugins.api.MonitoringEvent
import io.ktor.util.reflect.typeInfo import io.ktor.util.reflect.typeInfo
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -11,6 +13,10 @@ import kotlin.uuid.Uuid
object Buses { object Buses {
private val _projectBuses = ConcurrentHashMap<Uuid, SSEBus<ProjectEvent>>() private val _projectBuses = ConcurrentHashMap<Uuid, SSEBus<ProjectEvent>>()
private val _monitorBuses = ConcurrentHashMap<Uuid, SSEBus.MonitorSSEBus<
ServerMonitorEvent, ServerMonitorEvent.ApplicationStart, ServerMonitorEvent.Message,
ServerMonitorEvent.ApplicationEnd, ServerMonitorEvent.Backlog
>>()
val homeBus = SSEBus<HomeEvent>(typeOf<HomeEvent>(), serializer<HomeEvent>()) val homeBus = SSEBus<HomeEvent>(typeOf<HomeEvent>(), serializer<HomeEvent>())
val adminBus = SSEBus<AdminEvent>(typeOf<AdminEvent>(), serializer<AdminEvent>()) val adminBus = SSEBus<AdminEvent>(typeOf<AdminEvent>(), serializer<AdminEvent>())
@@ -19,9 +25,16 @@ object Buses {
SSEBus<ProjectEvent>(typeOf<ProjectEvent>(), serializer<ProjectEvent>()) SSEBus<ProjectEvent>(typeOf<ProjectEvent>(), serializer<ProjectEvent>())
} }
fun monitorBus(id: Uuid) = _monitorBuses.computeIfAbsent(id) {
SSEBus.MonitorSSEBus(
SSEBus(typeOf<ServerMonitorEvent>(), serializer<ServerMonitorEvent>())
) { start, events, end -> ServerMonitorEvent.Backlog(start, events, end) }
}
fun allBuses(): List<SSEBus<*>> { fun allBuses(): List<SSEBus<*>> {
val res = ArrayList<SSEBus<*>>(_projectBuses.size + 2) val res = ArrayList<SSEBus<*>>(_projectBuses.size + _monitorBuses.size + 2)
res.addAll(_projectBuses.values) res.addAll(_projectBuses.values)
res.addAll(_monitorBuses.values.map { it.bus })
res.add(homeBus) res.add(homeBus)
res.add(adminBus) res.add(adminBus)
return res return res
@@ -2,10 +2,17 @@ package com.jaytux.phoebench.server
import com.jaytux.phoebench.common.* import com.jaytux.phoebench.common.*
import com.jaytux.phoebench.server.db.DB import com.jaytux.phoebench.server.db.DB
import com.jaytux.phoebench.server.db.Meta
import com.jaytux.phoebench.server.db.Metas
import com.jaytux.phoebench.server.db.Project
import com.jaytux.phoebench.server.db.User import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.* import com.jaytux.phoebench.server.handlers.*
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProjectCSE
import io.ktor.http.* import io.ktor.http.*
import io.ktor.serialization.WebsocketContentConverter
import io.ktor.serialization.deserialize
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.* import io.ktor.server.auth.*
@@ -19,32 +26,45 @@ import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.sse.* import io.ktor.server.sse.*
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.CloseReason
import io.ktor.websocket.close
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.time.Clock
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
fun Application.module() { fun Application.module() {
DB.db DB.db
println("Entering module for PhoeBench Server version ${ServerVersion.VERSION}...") println("Entering module for PhoeBench Server version ${ServerVersion.VERSION}...")
if(Meta.single.revision < Metas.CURRENT_REVISION) {
DB.migrate(Meta.single.revision, Metas.CURRENT_REVISION)
}
install(ContentNegotiation) { install(ContentNegotiation) {
json() json()
} }
install(SSE) {}
val allowLocalhost = environment.config.propertyOrNull("ktor.cors.enableLocalhostOn")?.getString() ?: "0" val allowLocalhost = environment.config.propertyOrNull("ktor.cors.enableLocalhostOn")?.getString() ?: "0"
val safeOrigin = environment.config.propertyOrNull("ktor.cors.browserOrigin")?.getString() val safeOrigin = environment.config.propertyOrNull("ktor.cors.browserOrigin")?.getString()
install(CORS) { install(CORS) {
// -- methods --
allowMethod(HttpMethod.Options) allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Delete) allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch) allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Get) allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post) allowMethod(HttpMethod.Post)
// -- headers --
allowHeader(HttpHeaders.Accept) allowHeader(HttpHeaders.Accept)
allowHeader(HttpHeaders.Pragma)
allowHeader(HttpHeaders.Authorization) allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.ContentType) allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.CacheControl)
allowHeader(HttpHeaders.LastEventID)
// -- exposed headers --
exposeHeader(HttpHeaders.ContentType) exposeHeader(HttpHeaders.ContentType)
allowCredentials = true allowCredentials = true
allowNonSimpleContentTypes = true allowNonSimpleContentTypes = true
@@ -67,6 +87,12 @@ fun Application.module() {
} }
} }
install(SSE) {}
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
install(StatusPages) { install(StatusPages) {
status(HttpStatusCode.Forbidden) { call, status -> status(HttpStatusCode.Forbidden) { call, status ->
call.respond(status, ErrorResponse("Access Forbidden: CORS failed.")) call.respond(status, ErrorResponse("Access Forbidden: CORS failed."))
@@ -120,11 +146,17 @@ fun Application.module() {
deleteAdmin(Routes.Auth.User.delete, AuthHandler::deleteUser) deleteAdmin(Routes.Auth.User.delete, AuthHandler::deleteUser)
getAuth(Routes.home, ProjectHandler::home) getAuth(Routes.home, ProjectHandler::home)
postAuth(Routes.migrate0To1, ProjectHandler::migrate01)
postAuth(Routes.Project.new, ProjectHandler::createProject) postAuth(Routes.Project.new, ProjectHandler::createProject)
getAuth(Routes.Project.get, ProjectHandler::getProject) getAuth(Routes.Project.get, ProjectHandler::getProject)
patchAuth(Routes.Project.update, ProjectHandler::updateProject) patchAuth(Routes.Project.update, ProjectHandler::updateProject)
deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject) deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject)
deleteAuth(Routes.Project.rmMonitor, ProjectHandler::deleteMonitor)
postAuth(Routes.Benchmark.new, ProjectHandler::createBenchmark)
patchAuth(Routes.Benchmark.update, ProjectHandler::updateBenchmark)
deleteAuth(Routes.Benchmark.delete, ProjectHandler::deleteBenchmark)
postAuth(Routes.Version.new, ProjectHandler::createVersion) postAuth(Routes.Version.new, ProjectHandler::createVersion)
patchAuth(Routes.Version.update, ProjectHandler::updateVersion) patchAuth(Routes.Version.update, ProjectHandler::updateVersion)
@@ -133,13 +165,37 @@ fun Application.module() {
postAuth(Routes.Entry.new, ProjectHandler::createEntry) postAuth(Routes.Entry.new, ProjectHandler::createEntry)
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry) deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
sseAuth(Routes.SSE.home, { _, _ -> }) { _, _ -> Buses.homeBus } sseAuth(Routes.SSE.home, { _, _ -> }) { _, _, _ -> Buses.homeBus }
sseAdmin(Routes.SSE.admin) { _, _ -> Buses.adminBus } sseAdmin(Routes.SSE.admin) { _, _, _ -> Buses.adminBus }
sseAuth(Routes.SSE.projectSpecific, sseAuth(Routes.SSE.projectSpecific,
{ user, uuid -> { user, uuid ->
transaction { accessibleProject(user, uuid, false) } transaction { accessibleProject(user, uuid, false) }
} }
) { _, id -> Buses.projectBus(id) } ) { _, id, _ -> Buses.projectBus(id) }
sseAuth(Routes.SSE.monitor,
{ user, uuid ->
transaction { accessibleProject(user, uuid, true) }
}
) { _, id, sender ->
val bus = Buses.monitorBus(id)
val backlog = bus.onConnect()
sender(bus.bus.serializer, backlog)
bus.bus
}
cseAuth(Routes.CSE.monitor,
{ id, user -> transaction { accessibleProjectCSE(user, id, true) } },
ProjectHandler::monitorSetup) { _, _, bus, event ->
try {
when(event) {
is ClientMonitorEvent.ApplicationFinished -> bus.end(ServerMonitorEvent.ApplicationEnd(Clock.System.now(), event.exitCode))
is ClientMonitorEvent.Message -> bus.event(ServerMonitorEvent.Message(Clock.System.now(), event.msg, event.options, event.stream))
}
}
catch(e: IllegalStateException) {
throw RouteError.CSERouteError("Could not deliver message: ${e.message ?: "unknown error"}", CloseReasons.CONFLICT)
}
}
} }
get("{...}") { get("{...}") {
@@ -0,0 +1,35 @@
package com.jaytux.phoebench.server
class MutableBackLog<TStart, TEvent, TEnd> {
var start: TStart? = null
private set
private val _events = mutableListOf<TEvent>()
val events = _events.immutable()
var end: TEnd? = null
private set
fun registerStart(event: TStart) {
if(start != null) throw IllegalStateException("Start is already set.")
start = event
}
fun onEvent(event: TEvent) {
if(start == null) throw IllegalStateException("Start is not set yet.")
if(end != null) throw IllegalStateException("End is already set.")
_events += event
}
fun registerEnd(event: TEnd) {
if(start == null) throw IllegalStateException("Start is not set yet.")
if(end != null) throw IllegalStateException("End is already set.")
end = event
}
fun reset() {
start = null
_events.clear()
end = null
}
fun isRunning() = start != null && end == null
}
@@ -61,4 +61,32 @@ class SSEBus<T>(private val _containedType: KType, val serializer: KSerializer<T
map map
}.forEach { it.second.emit(Cancellation.error()) } }.forEach { it.second.emit(Cancellation.error()) }
} }
class MonitorSSEBus<TSuper, TStart : TSuper, TEvent : TSuper, TEnd: TSuper, TBacklog: TSuper>(
val bus: SSEBus<TSuper>, val backlog: MutableBackLog<TStart, TEvent, TEnd> = MutableBackLog(),
val mkBacklog: (start: TStart?, events: List<TEvent>, end: TEnd?) -> TBacklog
) {
val start: TStart? get() = backlog.start
val events: List<TEvent> get() = backlog.events
val end: TEnd? get() = backlog.end
suspend fun start(start: TStart) {
backlog.registerStart(start)
bus.sendAll(start)
}
suspend fun event(event: TEvent) {
backlog.onEvent(event)
bus.sendAll(event)
}
suspend fun end(end: TEnd) {
backlog.registerEnd(end)
bus.sendAll(end)
}
suspend fun onConnect() = mkBacklog(start, events, end)
fun isRunning() = backlog.isRunning()
}
} }
@@ -22,3 +22,7 @@ fun nowPlus(time: Int, unit: DateTimeUnit): Instant {
val now = Clock.System.now() val now = Clock.System.now()
return now.plus(time, unit, systemTZ) return now.plus(time, unit, systemTZ)
} }
infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
fun <T> MutableList<T>.immutable(): List<T> = this
@@ -1,9 +1,11 @@
package com.jaytux.phoebench.server.db package com.jaytux.phoebench.server.db
import com.jaytux.phoebench.server.DotEnv import com.jaytux.phoebench.server.DotEnv
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
object DB { object DB {
@@ -17,7 +19,7 @@ object DB {
transaction { transaction {
SchemaUtils.create(Users, Invites, RefreshTokens, Projects, Versions, Entries) SchemaUtils.create(Users, Invites, RefreshTokens, Projects, Versions, Entries)
val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Versions, Entries) val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Versions, Entries, Metas, EntriesV2, Benchmarks)
try { try {
migration.forEach { migration.forEach {
exec(it) exec(it)
@@ -29,4 +31,29 @@ object DB {
} }
} }
} }
fun migrate(from: Int, to: Int) = when(from) {
0 -> if(to == 1) migrate0To1() else null
else -> null
} ?: throw IllegalArgumentException("Migration $from -> $to is not defined")
private fun migrate0To1() {
transaction {
Users.update(where = { Users.isAdmin eq true }) { it[benchmarkLimit] = -1 }
Users.update(where = { Users.isAdmin eq false }) { it.update(benchmarkLimit, projectLimit) }
}
transaction {
User.all().forEach {
if(it.projects.count() == 0L) {
it.projectVersion = Projects.CURRENT_VERSION
return@forEach
}
}
}
transaction {
Meta.single.revision = 1
}
}
} }
@@ -1,10 +1,25 @@
package com.jaytux.phoebench.server.db package com.jaytux.phoebench.server.db
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.dao.Entity import org.jetbrains.exposed.v1.dao.Entity
import org.jetbrains.exposed.v1.dao.EntityClass import org.jetbrains.exposed.v1.dao.EntityClass
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
class Meta(id: EntityID<Int>) : Entity<Int>(id) {
companion object : EntityClass<Int, Meta>(Metas) {
val single by lazy {
transaction {
val got = all().limit(1).toList()
got.firstOrNull() ?: new {}
}
}
}
var revision by Metas.revision
}
class User(id: EntityID<Uuid>) : Entity<Uuid>(id) { class User(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, User>(Users) companion object : EntityClass<Uuid, User>(Users)
@@ -13,6 +28,8 @@ class User(id: EntityID<Uuid>) : Entity<Uuid>(id) {
var isAdmin by Users.isAdmin var isAdmin by Users.isAdmin
var isOwner by Users.isOwner var isOwner by Users.isOwner
var projectLimit by Users.projectLimit var projectLimit by Users.projectLimit
var benchmarkLimit by Users.benchmarkLimit
var projectVersion by Users.projectsVersion
val projects by Project referrersOn Projects.ownerId val projects by Project referrersOn Projects.ownerId
val sessions by RefreshToken referrersOn RefreshTokens.userId val sessions by RefreshToken referrersOn RefreshTokens.userId
@@ -42,8 +59,18 @@ class Project(id: EntityID<Uuid>) : Entity<Uuid>(id) {
var isPublic by Projects.isPublic var isPublic by Projects.isPublic
var owner by User referencedOn Projects.ownerId var owner by User referencedOn Projects.ownerId
val labels by Version referrersOn Versions.projectId val versions by Version referrersOn Versions.projectId
val entries by Entry referrersOn Entries.projectId val benchmarks by Benchmark referrersOn Benchmarks.projectId
}
class Benchmark(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, Benchmark>(Benchmarks)
var projectId by Benchmarks.projectId
var name by Benchmarks.name
val project by Project referencedOn Benchmarks.projectId
val entries by EntryV2 referrersOn EntriesV2.benchmarkId
} }
class Version(id: EntityID<Uuid>) : Entity<Uuid>(id) { class Version(id: EntityID<Uuid>) : Entity<Uuid>(id) {
@@ -72,3 +99,19 @@ class Entry(id: EntityID<Uuid>) : Entity<Uuid>(id) {
var project by Project referencedOn Entries.projectId var project by Project referencedOn Entries.projectId
var version by Version referencedOn Entries.versionId var version by Version referencedOn Entries.versionId
} }
class EntryV2(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, EntryV2>(EntriesV2)
var benchmarkId by EntriesV2.benchmarkId
var versionId by EntriesV2.versionId
var timestamp by EntriesV2.timestamp
var warmups by EntriesV2.warmups
var measurements by EntriesV2.measurements
var unit by EntriesV2.unit
var input by EntriesV2.input
var hardware by EntriesV2.hardware
var benchmark by Benchmark referencedOn EntriesV2.benchmarkId
var version by Version referencedOn EntriesV2.versionId
}
@@ -2,16 +2,26 @@ package com.jaytux.phoebench.server.db
import com.jaytux.phoebench.common.TimeUnit import com.jaytux.phoebench.common.TimeUnit
import org.jetbrains.exposed.v1.core.ReferenceOption import org.jetbrains.exposed.v1.core.ReferenceOption
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.core.dao.id.UuidTable import org.jetbrains.exposed.v1.core.dao.id.UuidTable
import org.jetbrains.exposed.v1.datetime.timestamp import org.jetbrains.exposed.v1.datetime.timestamp
import kotlin.time.Clock import kotlin.time.Clock
object Metas : IntIdTable() {
const val CURRENT_REVISION = 1
val revision = integer("revision").default(0)
}
object Users : UuidTable() { object Users : UuidTable() {
val username = varchar("username", 255).uniqueIndex() val username = varchar("username", 255).uniqueIndex()
val password = varchar("password", 255) val password = varchar("password", 255)
val isAdmin = bool("is_admin").default(false) val isAdmin = bool("is_admin").default(false)
val isOwner = bool("is_owner").default(false) val isOwner = bool("is_owner").default(false)
val projectLimit = integer("project_limit").default(1) val projectLimit = integer("project_limit").default(1)
val benchmarkLimit = integer("benchmark_limit").default(1)
val projectsVersion = integer("project_version").default(0)
} }
object Invites : UuidTable() { object Invites : UuidTable() {
@@ -25,6 +35,8 @@ object RefreshTokens : UuidTable() {
} }
object Projects : UuidTable() { object Projects : UuidTable() {
const val CURRENT_VERSION = 1
val ownerId = reference("owner_id", Users, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) val ownerId = reference("owner_id", Users, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val name = varchar("name", 255) val name = varchar("name", 255)
val isPublic = bool("is_public").default(false) val isPublic = bool("is_public").default(false)
@@ -34,6 +46,15 @@ object Projects : UuidTable() {
} }
} }
object Benchmarks : UuidTable() {
val projectId = reference("project_id", Projects, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val name = varchar("name", 255)
init {
uniqueIndex(projectId, name)
}
}
object Versions : UuidTable() { object Versions : UuidTable() {
val label = varchar("label", 255) val label = varchar("label", 255)
val color = varchar("color", 7) val color = varchar("color", 7)
@@ -51,3 +72,14 @@ object Entries : UuidTable() {
val input = varchar("input", 255) val input = varchar("input", 255)
val hardware = varchar("hardware", 255) val hardware = varchar("hardware", 255)
} }
object EntriesV2 : UuidTable() {
val benchmarkId = reference("benchmark_id", Benchmarks, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val versionId = reference("version_id", Versions, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val timestamp = timestamp("timestamp").default(Clock.System.now())
val warmups = list<Float>("warmups")
val measurements = list<Float>("measurements")
val unit = enumeration<TimeUnit>("unit")
val input = varchar("input", 255)
val hardware = varchar("hardware", 255)
}
@@ -4,18 +4,19 @@ import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrapped
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAdmin import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAdmin
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAuth import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAuth
import com.jaytux.phoebench.common.ApiRoute import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.CSERoute
import com.jaytux.phoebench.common.CloseReasons
import com.jaytux.phoebench.common.Elevation import com.jaytux.phoebench.common.Elevation
import com.jaytux.phoebench.common.EmptyRequest import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.ErrorResponse import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.SSERoute import com.jaytux.phoebench.common.SSERoute
import com.jaytux.phoebench.common.foldSuspend import com.jaytux.phoebench.common.foldSuspend
import com.jaytux.phoebench.server.Auth.setup
import com.jaytux.phoebench.server.SSEBus import com.jaytux.phoebench.server.SSEBus
import com.jaytux.phoebench.server.db.User import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.RouteError
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.serialization.deserialize
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.jwt.JWTPrincipal import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal import io.ktor.server.auth.principal
@@ -31,10 +32,15 @@ import io.ktor.server.routing.delete
import io.ktor.server.routing.patch import io.ktor.server.routing.patch
import io.ktor.server.sse.heartbeat import io.ktor.server.sse.heartbeat
import io.ktor.server.sse.sse import io.ktor.server.sse.sse
import io.ktor.server.websocket.webSocket
import io.ktor.sse.ServerSentEvent import io.ktor.sse.ServerSentEvent
import io.ktor.util.reflect.typeInfo import io.ktor.util.reflect.typeInfo
import io.ktor.utils.io.CancellationException import io.ktor.utils.io.CancellationException
import io.ktor.websocket.CloseReason
import io.ktor.websocket.close
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -165,7 +171,7 @@ inline fun <reified TReq: Any, reified TRes: Any> Route.patchAdmin(api: ApiRoute
inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrapSSE( inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrapSSE(
api: SSERoute<TParams, TEvent>, noinline extra: suspend (ApplicationCall, TParams) -> TInter, api: SSERoute<TParams, TEvent>, noinline extra: suspend (ApplicationCall, TParams) -> TInter,
noinline prepare: suspend (TInter, TParams) -> SSEBus<TEvent>, noinline prepare: suspend (TInter, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>,
noinline extract: suspend (SSEBus<TEvent>, TInter, TParams) -> SharedFlow<TFlow>, noinline extract: suspend (SSEBus<TEvent>, TInter, TParams) -> SharedFlow<TFlow>,
noinline handler: suspend (TFlow, sender: suspend (TEvent) -> Unit) -> Unit, noinline handler: suspend (TFlow, sender: suspend (TEvent) -> Unit) -> Unit,
noinline onCancel: suspend (SSEBus<TEvent>, TInter, TParams, CancellationException) -> Unit noinline onCancel: suspend (SSEBus<TEvent>, TInter, TParams, CancellationException) -> Unit
@@ -182,11 +188,14 @@ inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrap
) )
val inter = extra(call, params) val inter = extra(call, params)
val bus = prepare(inter, params) val bus = prepare(inter, params) { serializer, event -> send(ServerSentEvent(data = Json.encodeToString(serializer, event))) }
val stream = extract(bus, inter, params) val stream = extract(bus, inter, params)
try { try {
stream.collect { stream.collect {
handler(it) { ev -> send(ServerSentEvent(data = Json.encodeToString(bus.serializer, ev))) } handler(it) { ev ->
println("[SSE ${api.pattern}]: sending event!")
send(ServerSentEvent(data = Json.encodeToString(bus.serializer, ev)))
}
} }
} }
catch(e: CancellationException) { catch(e: CancellationException) {
@@ -214,7 +223,7 @@ inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrap
inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE( inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
api: SSERoute<TParams, TEvent>, api: SSERoute<TParams, TEvent>,
noinline verifyUser: suspend (User, TParams) -> Unit, noinline verifyUser: suspend (User, TParams) -> Unit,
noinline prepare: suspend (User, TParams) -> SSEBus<TEvent> noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
) = wrapSSE(api, ) = wrapSSE(api,
extra = { call, params -> extra = { call, params ->
val principal = call.principal<JWTPrincipal>() val principal = call.principal<JWTPrincipal>()
@@ -239,23 +248,113 @@ inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
onCancel = { bus, user, _, _ -> bus.disconnect(user.id.value) } onCancel = { bus, user, _, _ -> bus.disconnect(user.id.value) }
) )
inline fun <reified TParams: Any, reified TEvent: Any> Route.sse(api: SSERoute<TParams, TEvent>, noinline setup: suspend (TParams) -> SSEBus<TEvent>) = inline fun <reified TParams: Any, reified TEvent: Any> Route.sse(api: SSERoute<TParams, TEvent>, noinline prepare: suspend (TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>) {
wrapSSE(api, if(api.elevation != Elevation.UN_AUTH) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
wrapSSE(
api,
extra = { _, _ -> }, extra = { _, _ -> },
prepare = { _, params -> setup(params) }, prepare = { _, params, sender -> prepare(params, sender) },
extract = { bus, _, _ -> bus.unRegistered() }, extract = { bus, _, _ -> bus.unRegistered() },
handler = { it, sender -> sender(it) }, handler = { it, sender -> sender(it) },
onCancel = { _, _, _, _ -> } onCancel = { _, _, _, _ -> }
) )
}
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAuth(api: SSERoute<TParams, TEvent>, inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAuth(api: SSERoute<TParams, TEvent>,
noinline verifyUser: suspend (User, TParams) -> Unit, noinline setup: suspend (User, TParams) -> SSEBus<TEvent> noinline verifyUser: suspend (User, TParams) -> Unit, noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
) = wrapAuthSSE(api, verifyUser, setup) ) {
if(api.elevation != Elevation.AUTH) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthSSE(api, verifyUser, prepare)
}
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAdmin(api: SSERoute<TParams, TEvent>, inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAdmin(api: SSERoute<TParams, TEvent>,
noinline setup: suspend (User, TParams) -> SSEBus<TEvent> noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
) = wrapAuthSSE(api, { user, _ -> ) {
if(!user.isAdmin) { if(api.elevation != Elevation.ADMIN) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
throw RouteError("Admin access required", HttpStatusCode.Forbidden) 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 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)
for(frame in incoming) {
handler(params, inter, extra, converter.deserialize<TEvent>(frame))
}
}
catch(e: ClosedReceiveChannelException) {}
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 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,
handler = handler
)
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cse(
api: CSERoute<TParams, TEvent>,
noinline setup: suspend (TParams, Unit) -> TExtra,
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, { _, _ -> }, setup) { 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 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, handler)
}
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cseAdmin(
api: CSERoute<TParams, TEvent>, noinline setup: suspend (TParams, User) -> TExtra,
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, handler)
}
@@ -1,22 +1,35 @@
package com.jaytux.phoebench.server.handlers package com.jaytux.phoebench.server.handlers
import com.jaytux.phoebench.common.BenchmarkRequest
import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.BenchmarkSummary
import com.jaytux.phoebench.common.ClientMonitorEvent
import com.jaytux.phoebench.common.CloseReasons
import com.jaytux.phoebench.common.EmptyRequest import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.EmptyResponse import com.jaytux.phoebench.common.EmptyResponse
import com.jaytux.phoebench.common.EntryRequest import com.jaytux.phoebench.common.EntryRequest
import com.jaytux.phoebench.common.EntryResponse import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.HomeEvent import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.HomeResponse import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.Migrate0To1Request
import com.jaytux.phoebench.common.VersionRequest import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.VersionResponse import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.NamedID import com.jaytux.phoebench.common.NamedID
import com.jaytux.phoebench.common.PartialBenchmarkRequest
import com.jaytux.phoebench.common.PartialProjectRequest import com.jaytux.phoebench.common.PartialProjectRequest
import com.jaytux.phoebench.common.PartialVersionRequest import com.jaytux.phoebench.common.PartialVersionRequest
import com.jaytux.phoebench.common.ProjectEvent import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ProjectRequest import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.ProjectResponse import com.jaytux.phoebench.common.ProjectResponse
import com.jaytux.phoebench.common.ServerMonitorEvent
import com.jaytux.phoebench.server.Buses import com.jaytux.phoebench.server.Buses
import com.jaytux.phoebench.server.SSEBus
import com.jaytux.phoebench.server.app
import com.jaytux.phoebench.server.db.Benchmark
import com.jaytux.phoebench.server.db.Benchmarks
import com.jaytux.phoebench.server.db.Entries import com.jaytux.phoebench.server.db.Entries
import com.jaytux.phoebench.server.db.Entry import com.jaytux.phoebench.server.db.Entry
import com.jaytux.phoebench.server.db.EntryV2
import com.jaytux.phoebench.server.db.Version import com.jaytux.phoebench.server.db.Version
import com.jaytux.phoebench.server.db.Versions import com.jaytux.phoebench.server.db.Versions
import com.jaytux.phoebench.server.db.Project import com.jaytux.phoebench.server.db.Project
@@ -27,8 +40,14 @@ import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Transaction import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.notInList
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.time.Clock
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
object ProjectHandler { object ProjectHandler {
@@ -40,23 +59,49 @@ object ProjectHandler {
return when { return when {
isEditableBy(user) -> this isEditableBy(user) -> this
isPublic && !forEditing -> this isPublic && !forEditing -> this
else -> throw RouteError("Invalid project ID.", HttpStatusCode.NotFound) else -> throw RouteError("Invalid ID.", HttpStatusCode.NotFound)
} }
} }
context(trns: Transaction)
fun Benchmark.isAccessible(user: User, forEditing: Boolean): Pair<Project, Benchmark> {
return project.isAccessible(user, forEditing) to this
}
fun Transaction.accessibleProject(user: User, id: Uuid, forEditing: Boolean): Project { fun Transaction.accessibleProject(user: User, id: Uuid, forEditing: Boolean): Project {
val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound) val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound)
return project.isAccessible(user, forEditing) return project.isAccessible(user, forEditing)
} }
fun Transaction.accessibleProjectCSE(user: User, id: Uuid, forEditing: Boolean): Project {
val project = Project.findById(id) ?: throw RouteError.CSERouteError("Invalid project ID.", CloseReasons.NOT_FOUND)
return project.isAccessible(user, forEditing)
}
fun Transaction.accessibleBenchmark(user: User, id: Uuid, forEditing: Boolean): Pair<Project, Benchmark> {
val bench = Benchmark.findById(id) ?: throw RouteError("Invalid benchmark ID.", HttpStatusCode.NotFound)
return bench.isAccessible(user, forEditing)
}
fun Transaction.checkMigration(user: User) {
if(user.projectVersion < Projects.CURRENT_VERSION)
throw RouteError("You need to migrate your projects first.", HttpStatusCode.Conflict)
}
context(trns: Transaction) context(trns: Transaction)
private fun Project.toResponse(user: User) = ProjectResponse( private fun Project.toResponse(user: User) = ProjectResponse(
id.value, name, NamedID(owner.username, owner.id.value), isPublic, isEditableBy(user), id.value, name, NamedID(owner.username, owner.id.value), isPublic, isEditableBy(user),
labels.orderBy(Versions.label to SortOrder.ASC).map { VersionResponse(it.id.value, it.label, it.color, it.meta) }, benchmarks.orderBy(Benchmarks.name to SortOrder.ASC).map { b -> BenchmarkResponse(
entries.orderBy(Entries.timestamp to SortOrder.ASC).map { EntryResponse(it.id.value, it.version.id.value, it.timestamp, it.warmups, it.measurements, it.unit, it.input, it.hardware) }) b.id.value, b.name, b.entries.map { e ->
EntryResponse(e.id.value, e.benchmarkId.value, e.versionId.value, e.timestamp, e.warmups, e.measurements, e.unit, e.input, e.hardware)
}
) },
versions.orderBy(Versions.label to SortOrder.ASC).map { VersionResponse(it.id.value, it.label, it.color, it.meta) }
)
fun home(user: User, req: EmptyRequest) = transaction { fun home(user: User, req: EmptyRequest) = transaction {
val own = user.projects.orderBy(Projects.name to SortOrder.ASC).map { val own = user.projects.orderBy(Projects.name to SortOrder.ASC).mapNotNull {
if(it.name.startsWith("<migrate")) return@mapNotNull null
HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value)) HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value))
} }
@@ -64,10 +109,23 @@ object ProjectHandler {
HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value)) HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value))
} }
success(HomeResponse(user.username, user.isAdmin, user.projectLimit, own, publics)) val migrations = mutableListOf<HomeResponse.NeededMigration>()
var version = user.projectVersion
while(version < Projects.CURRENT_VERSION) {
migrations += when(version) {
0 -> HomeResponse.NeededMigration.Migrate0To1(user.projects.map { p ->
HomeResponse.NeededMigration.Migrate0To1.OldProject(p.id.value, p.name, p.versions.map { VersionResponse(it.id.value, it.label, it.color, it.meta) })
})
else -> throw IllegalArgumentException("No migration starting from $version possible.")
}
++version
}
success(HomeResponse(user.username, user.isAdmin, user.projectLimit, own, publics, migrations))
} }
suspend fun createProject(user: User, req: ProjectRequest) = transaction { suspend fun createProject(user: User, req: ProjectRequest) = transaction {
checkMigration(user)
if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit)) if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit))
throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict) throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict)
@@ -88,11 +146,13 @@ object ProjectHandler {
} }
fun getProject(user: User, req: Uuid) = transaction { fun getProject(user: User, req: Uuid) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req, false) val proj = accessibleProject(user, req, false)
success(proj.toResponse(user)) success(proj.toResponse(user))
} }
fun updateProject(user: User, req: Pair<Uuid, PartialProjectRequest>) = transaction { fun updateProject(user: User, req: Pair<Uuid, PartialProjectRequest>) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req.first, true) val proj = accessibleProject(user, req.first, true)
val changes = req.second val changes = req.second
changes.name?.let { proj.name = it } changes.name?.let { proj.name = it }
@@ -113,6 +173,7 @@ object ProjectHandler {
} }
fun deleteProject(user: User, req: Uuid) = transaction { fun deleteProject(user: User, req: Uuid) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req, true) val proj = accessibleProject(user, req, true)
proj.delete() proj.delete()
ServerScope.launch { ServerScope.launch {
@@ -124,8 +185,57 @@ object ProjectHandler {
success(EmptyResponse()) success(EmptyResponse())
} }
fun createVersion(user: User, req: VersionRequest) = transaction { fun createBenchmark(user: User, req: BenchmarkRequest) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req.projectId, true) val proj = accessibleProject(user, req.projectId, true)
val bench = Benchmark.new {
name = req.name
projectId = proj.id
}
val res = BenchmarkResponse(bench.id.value, bench.name, bench.entries.map {
EntryResponse(it.id.value, it.benchmarkId.value, it.versionId.value, it.timestamp, it.warmups, it.measurements, it.unit, it.input, it.hardware)
})
ServerScope.launch {
val projectBus = Buses.projectBus(proj.id.value)
if(proj.isPublic) projectBus.sendAll(ProjectEvent.NewBenchmark(res))
else projectBus.send(user.id.value, ProjectEvent.NewBenchmark(res))
}
success(res)
}
fun updateBenchmark(user: User, req: Pair<Uuid, PartialBenchmarkRequest>) = transaction {
checkMigration(user)
val (proj, bench) = accessibleBenchmark(user, req.first, true)
val changes = req.second
changes.name?.let { bench.name = it }
ServerScope.launch {
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.BenchmarkChanged(
BenchmarkSummary(bench.id.value, bench.name)
))
}
success(EmptyResponse())
}
fun deleteBenchmark(user: User, req: Uuid) = transaction {
checkMigration(user)
val (proj, bench) = accessibleBenchmark(user, req, true)
bench.delete()
ServerScope.launch {
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.BenchmarkDeleted(req))
}
success(EmptyResponse())
}
fun createVersion(user: User, req: VersionRequest) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req.benchmarkId, true)
if(req.color.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest) if(req.color.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest)
val ver = Version.new { val ver = Version.new {
label = req.name label = req.name
@@ -136,13 +246,14 @@ object ProjectHandler {
val res = VersionResponse(ver.id.value, ver.label, ver.color, ver.meta) val res = VersionResponse(ver.id.value, ver.label, ver.color, ver.meta)
ServerScope.launch { ServerScope.launch {
Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewVersion(res)) Buses.projectBus(req.benchmarkId).sendAll(ProjectEvent.NewVersion(res))
} }
success(res) success(res)
} }
fun updateVersion(user: User, req: Pair<Uuid, PartialVersionRequest>) = transaction { fun updateVersion(user: User, req: Pair<Uuid, PartialVersionRequest>) = transaction {
checkMigration(user)
val ver = Version.findById(req.first) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound) val ver = Version.findById(req.first) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
ver.project.isAccessible(user, true) ver.project.isAccessible(user, true)
val changes = req.second val changes = req.second
@@ -163,6 +274,7 @@ object ProjectHandler {
} }
fun deleteVersion(user: User, req: Uuid) = transaction { fun deleteVersion(user: User, req: Uuid) = transaction {
checkMigration(user)
val ver = Version.findById(req) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound) val ver = Version.findById(req) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
ver.project.isAccessible(user, true) ver.project.isAccessible(user, true)
ver.delete() ver.delete()
@@ -175,14 +287,15 @@ object ProjectHandler {
} }
fun createEntry(user: User, req: EntryRequest) = transaction { fun createEntry(user: User, req: EntryRequest) = transaction {
val proj = accessibleProject(user, req.projectId, true) checkMigration(user)
val entry = Entry.new { val (proj, bench) = accessibleBenchmark(user, req.benchmarkId, true)
val entry = EntryV2.new {
version = when(val l = Version.findById(req.version)) { version = when(val l = Version.findById(req.version)) {
null -> throw RouteError("Invalid version ID.", HttpStatusCode.NotFound) null -> throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
is Version if l.projectId.value != proj.id.value -> throw RouteError("Version is attached to a different project.", HttpStatusCode.Conflict) is Version if l.projectId.value != proj.id.value -> throw RouteError("Version is attached to a different project.", HttpStatusCode.Conflict)
else -> l else -> l
} }
project = proj benchmarkId = bench.id
measurements = req.measurements measurements = req.measurements
timestamp = req.timestamp timestamp = req.timestamp
warmups = req.warmups warmups = req.warmups
@@ -191,7 +304,8 @@ object ProjectHandler {
hardware = req.hardware hardware = req.hardware
} }
val response = EntryResponse(entry.id.value, entry.version.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit, entry.input, entry.hardware) val response = EntryResponse(entry.id.value, entry.benchmarkId.value,
entry.version.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit, entry.input, entry.hardware)
ServerScope.launch { ServerScope.launch {
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response)) Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response))
} }
@@ -199,11 +313,103 @@ object ProjectHandler {
} }
fun deleteEntry(user: User, req: Uuid) = transaction { fun deleteEntry(user: User, req: Uuid) = transaction {
val entry = Entry.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound) checkMigration(user)
entry.project.isAccessible(user, true) val entry = EntryV2.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
val (proj, bench) = entry.benchmark.isAccessible(user, true)
entry.delete() entry.delete()
ServerScope.launch { ServerScope.launch {
Buses.projectBus(entry.project.id.value).sendAll(ProjectEvent.EntryDeleted(req)) Buses.projectBus(proj.id.value).sendAll(ProjectEvent.EntryDeleted(req, bench.id.value))
}
success(EmptyResponse())
}
fun migrate01(user: User, req: Migrate0To1Request): Pair<HttpStatusCode, HomeResponse> {
val givenProjectIds = req.conversions.flatMap { it.oldProjectsAsBenchmarks }.toSet()
val totalProjectCount = req.conversions.sumOf { it.oldProjectsAsBenchmarks.size }
if(totalProjectCount != givenProjectIds.size)
throw RouteError("Each (legacy) project should only be bound to a single (migrated) project", HttpStatusCode.BadRequest)
val userProjects = transaction { user.projects.toList() }.map { it.id.value }.toSet()
if(givenProjectIds.any { it !in userProjects })
throw RouteError("Only a user's own projects should be migrated.", HttpStatusCode.Conflict)
transaction {
user.projects.forEach { it.name = "${it.name}#legacy" }
}
transaction {
req.conversions.forEach { (newName, isPublic, olds) ->
val proj = Project.new {
ownerId = user.id
name = newName
this.isPublic = isPublic
}
val versionMap = mutableMapOf<Uuid, EntityID<Uuid>>()
val duplicateMap = mutableMapOf<String, EntityID<Uuid>>()
Version.find { Versions.projectId inList olds }.forEach { oldVersion ->
val targetId = duplicateMap.getOrPut(oldVersion.label) {
oldVersion.projectId = proj.id
oldVersion.id
}
versionMap[oldVersion.id.value] = targetId
}
olds.forEach { oldId ->
val oldProj = accessibleProject(user, oldId, true)
val benchmark = Benchmark.new {
projectId = proj.id
name = oldProj.name.removeSuffix("#legacy")
}
Entry.find { Entries.projectId eq oldId }.forEach {
EntryV2.new {
benchmarkId = benchmark.id
versionId = versionMap[it.versionId.value]!!
timestamp = it.timestamp
warmups = it.warmups
measurements = it.measurements
unit = it.unit
input = it.input
hardware = it.hardware
}
it.delete()
}
}
Versions.deleteWhere {
(Versions.projectId inList olds) and (Versions.id notInList versionMap.values.toList())
}
Projects.deleteWhere {
Projects.id inList olds
}
}
user.projectVersion = 1
}
return home(user, EmptyRequest())
}
suspend fun monitorSetup(id: Uuid, user: User) = Buses.monitorBus(id).also {
try {
if(!it.isRunning()) it.backlog.reset()
it.start(ServerMonitorEvent.ApplicationStart(Clock.System.now()))
}
catch(e: IllegalStateException) {
throw RouteError.CSERouteError("Monitor may already be running. Please refrain from starting a second trace.", CloseReasons.CONFLICT)
}
}
fun deleteMonitor(user: User, req: Uuid) = transaction {
checkMigration(user)
accessibleProject(user, req, true)
val bus = Buses.monitorBus(req)
if(bus.isRunning()) throw RouteError("Monitor is still running", HttpStatusCode.Conflict)
bus.backlog.reset()
ServerScope.launch {
bus.bus.sendAll(ServerMonitorEvent.Cleared)
} }
success(EmptyResponse()) success(EmptyResponse())
} }
@@ -1,6 +1,7 @@
package com.jaytux.phoebench.server.handlers package com.jaytux.phoebench.server.handlers
import com.jaytux.phoebench.common.Auth import com.jaytux.phoebench.common.Auth
import com.jaytux.phoebench.common.CloseReasons
import com.jaytux.phoebench.common.ErrorResponse import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.server.db.User import com.jaytux.phoebench.server.db.User
import io.ktor.http.ContentType import io.ktor.http.ContentType
@@ -13,6 +14,7 @@ import io.ktor.server.response.respondText
import io.ktor.server.routing.RoutingCall import io.ktor.server.routing.RoutingCall
import io.ktor.server.routing.RoutingContext import io.ktor.server.routing.RoutingContext
import io.ktor.util.logging.KtorSimpleLogger import io.ktor.util.logging.KtorSimpleLogger
import io.ktor.websocket.CloseReason
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -75,4 +77,9 @@ open class RouteError(message: String, val status: HttpStatusCode = HttpStatusCo
fun unauthorized(message: String): Nothing = fun unauthorized(message: String): Nothing =
throw RouteError(message, HttpStatusCode.Unauthorized) throw RouteError(message, HttpStatusCode.Unauthorized)
} }
class CSERouteError(message: String, val code: Short) : RouteError(message, HttpStatusCode.NotImplemented) {
constructor(message: String, code: CloseReason.Codes) : this(message, code.code)
constructor(message: String, code: CloseReasons) : this(message, code.code)
}
} }