Author SHA1 Message Date
jaytux 589649d9f6 Server: patch crashing applications blocks monitor 2026-09-02 15:11:06 +02:00
jaytux ae7724b3c4 UI: re-render when versions change 2026-09-01 20:12:29 +02:00
jaytux ef5ccb9274 UI: auto-scrolling 2026-09-01 16:57:21 +02:00
jaytux 9e010c7873 Server: harden against crashes 2026-09-01 16:31:00 +02:00
jaytux fce1c097d4 Java Preferences sync bug 2026-09-01 16:01:18 +02:00
jaytux b5c4e150a5 UI: removing benchmarks 2026-09-01 15:28:52 +02:00
jaytux 126a3bfff2 Clients: use http->ws, https->wss 2026-09-01 15:17:39 +02:00
jaytux 563ee98d03 ANSI update 2026-09-01 14:58:17 +02:00
jaytux 7f92df1ca5 CLI: fix keyring backend bug 2026-09-01 14:09:37 +02:00
jaytux 768bb0c650 Fix window resize (desktop) 2026-09-01 11:54:56 +02:00
jaytux 85902620da Script monitoring 2026-09-01 11:47:10 +02:00
jaytux 8da391b6ce Web UI: width detection 2026-08-30 21:24:56 +02:00
jaytux bc9861900e UI clients: fix narrow vs wide UI 2026-08-30 21:11:18 +02:00
jaytux 1d10802140 CLI: formatting 2026-08-27 15:23:07 +02:00
45 changed files with 1558 additions and 348 deletions
+2 -2
View File
@@ -16,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
+1 -1
View File
@@ -14,4 +14,4 @@ repositories {
mavenCentral() mavenCentral()
} }
version = PhoebenchVersion(1, 1, 0, "") version = PhoebenchVersion(1, 2, 1, "")
+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
}
}
+28 -2
View File
@@ -5,10 +5,11 @@ 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 = rootProject.version as PhoebenchVersion version = PhoebenchVersion(1, 2, 1, "patch0")
if((version as PhoebenchVersion) < (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") throw GradleException("CLI Client version must be at least as high as protocol/common version")
@@ -28,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"))
@@ -42,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 {
@@ -75,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.*
@@ -227,6 +229,24 @@ object CLI {
} }
} }
@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)
}
}
@Suppress("unused") @Suppress("unused")
class Version : CliktCommand(name = "version") { class Version : CliktCommand(name = "version") {
override fun run() { override fun run() {
@@ -1,6 +1,7 @@
package com.jaytux.phoebench.clients.cli package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.common.ApiRoute import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.CSERoute
import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.ErrorResponse import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.IClient import com.jaytux.phoebench.common.IClient
@@ -9,15 +10,31 @@ import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.TokenResponse import com.jaytux.phoebench.common.TokenResponse
import com.jaytux.phoebench.common.error import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.foldSuspend import com.jaytux.phoebench.common.foldSuspend
import com.jaytux.phoebench.common.value
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.ResponseException
import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.websocket.WebSocketException
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.sendSerialized
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.url
import io.ktor.http.HttpMethod
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import io.ktor.util.reflect.TypeInfo
import io.ktor.util.reflect.typeInfo
import io.ktor.utils.io.CancellationException import io.ktor.utils.io.CancellationException
import io.ktor.websocket.CloseReason
import kotlinx.coroutines.asExecutor import kotlinx.coroutines.asExecutor
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import java.net.URL
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
object Client { object Client {
@@ -68,6 +85,10 @@ object Client {
} }
} }
} }
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
} }
init { init {
@@ -91,6 +112,19 @@ object Client {
suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> = suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> =
callRoute(_client, route, body) callRoute(_client, route, body)
suspend fun <TParams: Any, TEvent: Any> callCSE(
route: CSERoute<TParams, TEvent>, params: TParams,
body: suspend (sender: suspend (TEvent) -> Unit) -> Unit
): Either<ErrorResponse, Unit> {
try {
val client = IClient.Default(_client, _server ?: throw IllegalStateException("No server URL set."))
return route.call(client, params, body)
}
catch(e: CancellationException) {
return ErrorResponse("Event stream disconnected.").error()
}
}
fun onLogin(tokens: TokenResponse) { fun onLogin(tokens: TokenResponse) {
_refreshToken = tokens.refresh _refreshToken = tokens.refresh
_accessToken = tokens.access _accessToken = tokens.access
@@ -0,0 +1,83 @@
package com.jaytux.phoebench.clients.cli
import com.github.pgreze.process.Redirect
import com.github.pgreze.process.process
import com.jaytux.phoebench.clients.cli.ProjectHandlers.toId
import com.jaytux.phoebench.common.ANSI
import com.jaytux.phoebench.common.ClientMonitorEvent
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.Stream
import com.jaytux.phoebench.common.bind
import com.jaytux.phoebench.common.fold
import io.ktor.http.HttpMethod
import io.ktor.utils.io.CancellationException
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Instant
object MonitorHandler {
private fun filterAnsi(line: String): Pair<String, List<ANSI>> {
val builder = StringBuilder()
val codes = mutableSetOf<ANSI>()
var remaining = line
while(remaining.isNotEmpty()) {
val next = remaining.indexOf("\u001B[")
if(next < 0) {
builder.append(remaining)
remaining = ""
}
else {
builder.append(remaining.substring(0, next))
val end = remaining.indexOf('m', startIndex = next + 2)
if(end == -1) return (line to listOf()) // invalid...
codes += remaining.substring(next + 2, end).split(';').mapNotNull {
it.toIntOrNull()?.let { i -> ANSI.ansiMapping[i] }
}
remaining = remaining.substring(end + 1)
}
}
return builder.toString() to codes.toList()
}
fun monitor(find: CLI.Commands.Project.IProjectIdentification?, command: String, args: List<String>) {
val project = ProjectHandlers.ensureProjectIdentification(find)
val combinedCommand = arrayOf(command, *args.toTypedArray())
tryAuthenticated {
project.toId().bind { id ->
Client.callCSE(Routes.CSE.monitor, id) { sender ->
try {
val res = process(
*combinedCommand,
stdin = null,
stdout = Redirect.Consume { flow ->
flow.collect { line ->
val (updLine, options) = filterAnsi(line)
sender(ClientMonitorEvent.Message(msg = updLine, stream = Stream.STDOUT, options = options))
println(line)
}
},
stderr = Redirect.Consume { flow ->
flow.collect { line ->
val (updLine, options) = filterAnsi(line)
sender(ClientMonitorEvent.Message(msg = updLine, stream = Stream.STDERR, options = options))
System.err.println(line)
}
}
)
sender(ClientMonitorEvent.ApplicationFinished(res.resultCode))
}
catch(e: CancellationException) {
throw e
}
catch(e: Exception) {
System.err.println("Launch failed: ${e.message} (${e::class.simpleName})")
}
}
}
}.fold({
System.err.println("Failed to run: ${it.msg}")
}) {}
}
}
@@ -2,6 +2,7 @@ package com.jaytux.phoebench.clients.cli
import com.github.javakeyring.BackendNotSupportedException import com.github.javakeyring.BackendNotSupportedException
import com.github.javakeyring.Keyring import com.github.javakeyring.Keyring
import com.github.javakeyring.PasswordAccessException
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
@@ -10,7 +11,7 @@ import kotlin.uuid.Uuid
object PersistentStorage { object PersistentStorage {
private val json = Json private val json = Json
const val SERVICE = "com.jaytux.phoebench.cli" const val SERVICE = PersistenceConstants.SERVICE
private var _backend: IBackend = KeyringBackend private var _backend: IBackend = KeyringBackend
interface IBackend { interface IBackend {
@@ -22,7 +23,11 @@ object PersistentStorage {
private object KeyringBackend : IBackend { private object KeyringBackend : IBackend {
override fun read(key: String): String? = try { override fun read(key: String): String? = try {
Keyring.create().use { it.getPassword(SERVICE, key) } Keyring.create().use { it.getPassword(SERVICE, key) }
} catch(e: Exception) { }
catch(e: PasswordAccessException) {
return null
}
catch(e: BackendNotSupportedException) {
_backend = PreferencesBackend _backend = PreferencesBackend
_backend.read(key) _backend.read(key)
} }
@@ -52,6 +57,7 @@ object PersistentStorage {
override fun write(key: String, value: String) { override fun write(key: String, value: String) {
prefs.put(key, value) prefs.put(key, value)
prefs.flush()
} }
override fun erase(key: String) { override fun erase(key: String) {
@@ -78,9 +78,9 @@ object ProjectHandlers {
println("\n${it.benchmarks.size} benchmarks:") println("\n${it.benchmarks.size} benchmarks:")
it.benchmarks.forEach { b -> it.benchmarks.forEach { b ->
println(" - [${b.id}] ${b.name} with ${b.entries.size} entries:") println(" - [${b.id}] BENCHMARK ${b.name} (with ${b.entries.size} entries):")
b.entries.forEach { e -> b.entries.forEach { e ->
println(" - [${e.id}] ENTRY versioned ${map[e.versionId]?.name ?: "<invalid version>"} at ${e.timestamp.fmt()} " + println(" - [${e.id}] ENTRY versioned ${map[e.versionId]?.name ?: "<invalid version>"} at ${e.timestamp.fmt()} " +
"(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})") "(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})")
} }
} }
+1 -18
View File
@@ -13,7 +13,7 @@ plugins {
alias(libs.plugins.buildconfig) alias(libs.plugins.buildconfig)
} }
version = PhoebenchVersion(1, 1, 1) version = PhoebenchVersion(1, 2, 1, "autoscroll")
if((version as PhoebenchVersion) < (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") throw GradleException("UI Clients version must be at least as high as protocol/common version")
@@ -142,23 +142,6 @@ compose.desktop {
} }
} }
fun envValue(key: String): String? {
val fromEnv = providers.environmentVariable(key).orNull
if(fromEnv != null) return fromEnv
val envFile = rootProject.file(".env")
if(!envFile.exists()) return null
return envFile.useLines { lines ->
lines.map { it.trim() }.filter { it.isNotBlank() && !it.startsWith('#') && '=' in it }
.map { line ->
val (k, v) = line.split('=', limit = 2)
k.trim() to v.trim().removeSurrounding("\"").removeSurrounding("'")
}
.firstOrNull { it.first == key }?.second
}
}
buildConfig { buildConfig {
generateAtSync = false generateAtSync = false
useKotlinOutput { internalVisibility = false } useKotlinOutput { internalVisibility = false }
@@ -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
@@ -4,8 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTransferData 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.KSerializer
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -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,9 +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.pow import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.random.Random import kotlin.random.Random
import kotlin.random.nextInt import kotlin.random.nextInt
import kotlin.time.Clock import kotlin.time.Clock
@@ -145,3 +152,15 @@ fun List<Float>.geomean(): Float {
for(value in this) prod *= value for(value in this) prod *= value
return prod.pow(1.0f / size.toFloat()) 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)
}
@@ -38,6 +38,8 @@ interface IProjectRepo {
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> =
@@ -74,6 +76,9 @@ interface IProjectRepo {
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
@@ -18,7 +19,9 @@ 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
@@ -87,6 +90,7 @@ class ProjectVM(
private val _currentBenchmark = mutableStateOf(0) 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()
@@ -97,8 +101,10 @@ class ProjectVM(
val currentBenchmark = _currentBenchmark.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 {
@@ -132,8 +138,15 @@ class ProjectVM(
when(event) { when(event) {
ProjectEvent.Deleted -> back() ProjectEvent.Deleted -> back()
is ProjectEvent.EntryDeleted -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmarkId }) { it.remove(event.id) } is ProjectEvent.EntryDeleted -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmarkId }) { it.remove(event.id) }
is ProjectEvent.VersionChanged -> _labels.value += (event.label.id to Version.fromResponse(event.label)) is ProjectEvent.VersionChanged -> {
is ProjectEvent.VersionDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id } val updVersion = Version.fromResponse(event.label)
_labels.value += (event.label.id to updVersion)
_benchmarks.value = _benchmarks.value.map { b -> b.copy(entries = b.entries.map { e -> if(e.label.id == event.label.id) e.copy(label = updVersion) else e }) }
}
is ProjectEvent.VersionDeleted -> {
_labels.value = _labels.value.filter { it.value.id != event.id }
_benchmarks.value = _benchmarks.value.map { it.copy(entries = it.entries.filter { e -> e.label.id != event.id }) }
}
is ProjectEvent.NewEntry -> { is ProjectEvent.NewEntry -> {
_benchmarks.value = _benchmarks.value.replace({ it.id == event.entry.benchmarkId }) { it.add(Entry.fromResponse(event.entry, _labels.value, _benchmarks.value = _benchmarks.value.replace({ it.id == event.entry.benchmarkId }) { it.add(Entry.fromResponse(event.entry, _labels.value,
{ input -> _inputs.value += input }, { hw -> _hardware.value += hw })) } { input -> _inputs.value += input }, { hw -> _hardware.value += hw })) }
@@ -218,7 +231,7 @@ class ProjectVM(
val (updated, idx) = _benchmarks.value.insortIdx( val (updated, idx) = _benchmarks.value.insortIdx(
Benchmark.fromResponse(bench, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw }) Benchmark.fromResponse(bench, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw })
) { it.name } ) { it.name }
_currentBenchmark.value?.let { curr -> _currentBenchmark.value.let { curr ->
if(idx != null) { if(idx != null) {
if(idx < curr) _currentBenchmark.value = curr + 1 if(idx < curr) _currentBenchmark.value = curr + 1
} }
@@ -276,4 +289,37 @@ class ProjectVM(
fun selectBenchmark(id: Uuid) { fun selectBenchmark(id: Uuid) {
_currentBenchmark.value = maxOf(_benchmarks.value.indexOfFirst { it.id == id }, 0) _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
@@ -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
)
@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -18,17 +19,23 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.composables.icons.lucide.* import com.composables.icons.lucide.*
import com.jaytux.phoebench.clients.* import com.jaytux.phoebench.clients.*
import com.jaytux.phoebench.clients.data.ProjectVM import com.jaytux.phoebench.clients.data.ProjectVM
import com.jaytux.phoebench.clients.data.mutableStateSetFrom import com.jaytux.phoebench.clients.data.mutableStateSetFrom
import com.jaytux.phoebench.clients.data.mutableStateSetOf import com.jaytux.phoebench.clients.data.mutableStateSetOf
import com.jaytux.phoebench.clients.theme.GlobalUiMode
import com.jaytux.phoebench.clients.theme.UiMode
import com.jaytux.phoebench.clients.theme.linkColor
import com.jaytux.phoebench.common.* import com.jaytux.phoebench.common.*
import com.kborowy.colorpicker.KolorPicker import com.kborowy.colorpicker.KolorPicker
import io.github.koalaplot.core.bar.DefaultBar import io.github.koalaplot.core.bar.DefaultBar
@@ -53,6 +60,7 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
val versions by vm.versions val versions by vm.versions
val benchmarks by vm.benchmarks val benchmarks by vm.benchmarks
val currentBenchmark by vm.currentBenchmark val currentBenchmark by vm.currentBenchmark
val monitor by vm.monitorMessages
var editing by remember { mutableStateOf(false) } var editing by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(false) } var deleting by remember { mutableStateOf(false) }
@@ -60,16 +68,20 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
Column(Modifier.padding(all = 15.dp)) { Column(Modifier.padding(all = 15.dp)) {
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
Text("Project ${name ?: "Unnamed Project"}", style = MaterialTheme.typography.headlineMedium) Text("Project ${name ?: "Unnamed Project"}", style = MaterialTheme.typography.headlineMedium)
if(editable) { if (editable) {
Spacer(Modifier.width(25.dp)) Spacer(Modifier.width(25.dp))
IconButton({ editing = true }) { IconButton({ editing = true }) {
Icon(Lucide.Pencil, "Edit project details") Icon(Lucide.Pencil, "Edit project details")
}
IconButton({ deleting = true }) {
Icon(Lucide.Trash, "Delete project")
}
IconButton({ vm.openMonitor() }) {
Icon(Lucide.SquareTerminal, "Open monitor")
}
} }
IconButton({ deleting = true }) {
Icon(Lucide.Trash, "Delete project")
}
}
} }
owner?.let { Text("${if(public) "Public" else "Private"} project by user $it") } owner?.let { Text("${if(public) "Public" else "Private"} project by user $it") }
Spacer(Modifier.height(15.dp)) Spacer(Modifier.height(15.dp))
@@ -108,6 +120,98 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
vm.mkBenchmark(it) vm.mkBenchmark(it)
addingBenchmark = false addingBenchmark = false
} }
monitor?.let { MonitorDialog(name ?: "Unnamed Project", it, vm::closeMonitor, vm::clearMonitor) }
}
@Composable
fun MonitorMessage(msg: ServerMonitorEvent.ITextEvent) = when(msg) {
is ServerMonitorEvent.ApplicationEnd -> Text("[${msg.time.fmt()}] Application finished with exit code ${msg.exitCode}.", fontStyle = FontStyle.Italic)
is ServerMonitorEvent.ApplicationStart -> Text("[${msg.time.fmt()}] Application started.", fontStyle = FontStyle.Italic)
is ServerMonitorEvent.Message -> {
val ansi = ANSI.unpack(msg.options).toSet()
val weight = if(ANSI.BOLD in ansi) FontWeight.Bold else null
val decoration = if(ANSI.UNDERLINE in ansi) TextDecoration.Underline else null
val color = when {
ANSI.FG_RED in ansi -> Color.Red
ANSI.FG_GREEN in ansi -> Color.Green
ANSI.FG_YELLOW in ansi -> Color.Yellow
ANSI.FG_BLUE in ansi -> Color.Blue
ANSI.FG_GRAY in ansi -> Color.Gray
else -> LocalContentColor.current
}
val background = when {
ANSI.BG_RED in ansi -> Color.Red
ANSI.BG_GREEN in ansi -> Color.Green
ANSI.BG_YELLOW in ansi -> Color.Yellow
ANSI.BG_BLUE in ansi -> Color.Blue
ANSI.BG_GRAY in ansi -> Color.Gray
else -> null
}?.let { Modifier.background(it) } ?: Modifier
Box(background.fillMaxWidth()) {
Text(
"[${msg.timeStamp.fmt()}] [${if (msg.stream == Stream.STDOUT) 'O' else 'E'}] ${msg.message}",
fontWeight = weight, textDecoration = decoration, color = color
)
}
}
}
@Composable
fun MonitorDialog(name: String, logs: List<ServerMonitorEvent.ITextEvent>, onClose: () -> Unit, onClear: () -> Unit) {
var autoFollow by remember { mutableStateOf(true) }
val state = rememberLazyListState()
LaunchedEffect(autoFollow, logs) {
if(autoFollow) {
state.animateScrollToItem(logs.size - 1)
}
}
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Surface(Modifier.padding(15.dp).fillMaxHeight(0.8f).widthIn(min = 300.dp, max = 2000.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp)) {
Text("Monitor for $name", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(10.dp))
if(logs.isEmpty()) {
Box(Modifier.weight(1f)) {
Box(Modifier.fillMaxHeight(0.33f).fillMaxWidth()) {
Text("No monitor logs.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
}
}
}
else {
CompositionLocalProvider(LocalTextStyle provides LocalTextStyle.current.makeMonospaced()) {
Box(Modifier.weight(1f)) {
LazyColumn(Modifier.fillMaxHeight().fillMaxWidth().padding(10.dp).background(MaterialTheme.colorScheme.surfaceDim), state) {
items(logs) {
MonitorMessage(it)
}
}
Box(Modifier.align(Alignment.TopEnd).padding(25.dp)) {
val colors = if(autoFollow)
IconButtonDefaults.iconButtonColors(containerColor = LocalContentColor.current, contentColor = MaterialTheme.colorScheme.surface)
else IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.surface, contentColor = LocalContentColor.current)
IconButton({ autoFollow = !autoFollow }, colors = colors) {
Icon(Lucide.ArrowDownToLine, "Follow")
}
}
}
}
}
Spacer(Modifier.height(10.dp))
Row {
Button(onClear, Modifier.weight(0.5f)) { Text("Clear Monitor") }
Spacer(Modifier.width(10.dp))
Button(onClose, Modifier.weight(0.5f)) { Text("Close Monitor") }
}
}
}
}
} }
@Composable @Composable
@@ -116,6 +220,7 @@ fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM
var addingLabel by remember { mutableStateOf(false) } var addingLabel by remember { mutableStateOf(false) }
var addingData by remember { mutableStateOf(false) } var addingData by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf(false) } var editing by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(false) }
Surface(Modifier.fillMaxSize().padding(8.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) { Surface(Modifier.fillMaxSize().padding(8.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
@@ -134,6 +239,9 @@ fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM
IconButton({ editing = true }) { IconButton({ editing = true }) {
Icon(Lucide.Pencil, "Edit benchmark") Icon(Lucide.Pencil, "Edit benchmark")
} }
IconButton({ deleting = true }) {
Icon(Lucide.Trash, "Remove benchmark")
}
Icon(Lucide.ChevronDown, "Select benchmark...") Icon(Lucide.ChevronDown, "Select benchmark...")
} }
} }
@@ -150,7 +258,7 @@ fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM
} }
} }
} }
Box(Modifier.fillMaxSize().padding(15.dp)) { Box(Modifier.fillMaxSize()) {
ProjectPlotArea(vm, currentBenchmark) ProjectPlotArea(vm, currentBenchmark)
if(editable) { if(editable) {
@@ -160,7 +268,7 @@ fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM
} }
} }
Column(Modifier.align(Alignment.BottomEnd).padding(25.dp), horizontalAlignment = Alignment.End) { Column(Modifier.align(Alignment.BottomEnd).padding(40.dp), horizontalAlignment = Alignment.End) {
if (addOpen) { if (addOpen) {
var lblWidth by mutableStateOf(1000.dp) var lblWidth by mutableStateOf(1000.dp)
var dataWidth by mutableStateOf(1000.dp) var dataWidth by mutableStateOf(1000.dp)
@@ -232,6 +340,8 @@ fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM
} }
if(editing) EditBenchmarkDialog(currentBenchmark, { editing = false }) { vm.updateBenchmark(currentBenchmark.id, it) } if(editing) EditBenchmarkDialog(currentBenchmark, { editing = false }) { vm.updateBenchmark(currentBenchmark.id, it) }
if(deleting) ConfirmDeleteBenchmarkDialog(currentBenchmark, { deleting = false }) { vm.deleteBenchmark(currentBenchmark.id); deleting = false }
} }
@Composable @Composable
@@ -276,6 +386,21 @@ fun ConfirmDeleteProjectDialog(name: String, onCancel: () -> Unit, onDelete: ()
} }
} }
@Composable
fun ConfirmDeleteBenchmarkDialog(benchmark: ProjectVM.Benchmark, onCancel: () -> Unit, onDelete: () -> Unit) {
Dialog(onDismissRequest = onCancel) {
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) {
Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(10.dp))
Text("Are you sure you want to delete ${benchmark.name}?")
Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
}
}
}
}
@Composable @Composable
fun AddBenchmarkDialog(onCancel: () -> Unit, onAdd: (name: String) -> Unit) { fun AddBenchmarkDialog(onCancel: () -> Unit, onAdd: (name: String) -> Unit) {
Dialog(onDismissRequest = onCancel) { Dialog(onDismissRequest = onCancel) {
@@ -349,13 +474,7 @@ fun QuickVersion(lbl: ProjectVM.Version, hollow: Boolean = false) {
} }
Spacer(Modifier.width(15.dp)) Spacer(Modifier.width(15.dp))
Column { Column {
Row { Text(lbl.name, overflow = TextOverflow.Ellipsis, maxLines = 1)
Text(lbl.name)
Text(" ${lbl.colorStr}", Modifier.align(Alignment.Bottom),
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(alpha = 0.75f),
)
}
if(lbl.meta.isNotBlank()) if(lbl.meta.isNotBlank())
Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(0.75f)) color = LocalContentColor.current.copy(0.75f))
@@ -536,7 +655,9 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
val allInputs by vm.inputs val allInputs by vm.inputs
val allHardware by vm.hardware val allHardware by vm.hardware
val visibleInputs by remember(entries, allInputs) { mutableStateOf(allInputs intersect entries.map { it.input }.toSet()) } val visibleInputs by remember(entries, allInputs) {
mutableStateOf(allInputs intersect entries.map { it.input }.toSet())
}
var filteredEntries by remember { mutableStateOf(entries) } var filteredEntries by remember { mutableStateOf(entries) }
var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) } var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) }
@@ -552,16 +673,26 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
val enabledHardware = remember(allHardware) { mutableStateSetOf<String>() } val enabledHardware = remember(allHardware) { mutableStateSetOf<String>() }
val enabledInputs = remember(allInputs) { mutableStateSetOf<String>() } val enabledInputs = remember(allInputs) { mutableStateSetOf<String>() }
LaunchedEffect(entries, allInputs, allHardware, timeFilter, timeMin, timeMax, versionFilterRev, inputFilterRev, filterHardware) { LaunchedEffect(
entries,
allInputs,
allHardware,
timeFilter,
timeMin,
timeMax,
versionFilterRev,
inputFilterRev,
filterHardware
) {
val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive) val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
enabledHardware.clear() enabledHardware.clear()
enabledInputs.clear() enabledInputs.clear()
filteredEntries = entries.filter { entry -> filteredEntries = entries.filter { entry ->
if(entry.label !in versionFilter) return@filter false if (entry.label !in versionFilter) return@filter false
if(!(entry.timeStamp inRange timeRange)) return@filter false if (!(entry.timeStamp inRange timeRange)) return@filter false
if(entry.input !in inputFilter) return@filter false if (entry.input !in inputFilter) return@filter false
if(filterHardware != null && entry.hardware != filterHardware) return@filter false if (filterHardware != null && entry.hardware != filterHardware) return@filter false
enabledHardware.add(entry.hardware) enabledHardware.add(entry.hardware)
enabledInputs.add(entry.input) enabledInputs.add(entry.input)
@@ -582,7 +713,7 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
var maxY = 0f var maxY = 0f
renderableEntries = filteredEntries.map { entry -> renderableEntries = filteredEntries.map { entry ->
val use = if(displayWarmups) entry.warmups else entry.measurements val use = if (displayWarmups) entry.warmups else entry.measurements
maxX = maxOf(maxX, use.size.toFloat()) maxX = maxOf(maxX, use.size.toFloat())
use.mapIndexed { idx, it -> use.mapIndexed { idx, it ->
val converted = entry.nativeUnit.convertTo(currentUnit, it) val converted = entry.nativeUnit.convertTo(currentUnit, it)
@@ -617,15 +748,23 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
var max = 0.0f var max = 0.0f
baselineComparison = baseline?.let { bl -> baselineComparison = baseline?.let { bl ->
class MeanAccumulator(var sum: Float = 0.0f, var count: Float = 0.0f) { class MeanAccumulator(var sum: Float = 0.0f, var count: Float = 0.0f) {
fun add(f: Float) { sum += f; count += 1.0f } fun add(f: Float) {
fun mean() = if(count == 0.0f) 0.0f else sum / count sum += f; count += 1.0f
}
fun add(f: Float, countsAs: Int) {
sum += f; count += countsAs.toFloat()
}
fun mean() = if (count == 0.0f) 0.0f else (sum / count)
} }
val perVersion = mutableMapOf<ProjectVM.Version, MutableMap<String, MeanAccumulator>>() val perVersion = mutableMapOf<ProjectVM.Version, MutableMap<String, MeanAccumulator>>()
filteredEntries.forEach { entry -> filteredEntries.forEach { entry ->
val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum() val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum()
perVersion.getOrPut(entry.label) { mutableMapOf() } perVersion.getOrPut(entry.label) { mutableMapOf() }
.getOrPut(entry.input) { MeanAccumulator() } .getOrPut(entry.input) { MeanAccumulator() }
.add(corrected) .add(corrected, entry.measurements.size)
} }
val subMeans = perVersion.mapValues { (_, v) -> v.mapValues { (_, acc) -> acc.mean() } } val subMeans = perVersion.mapValues { (_, v) -> v.mapValues { (_, acc) -> acc.mean() } }
@@ -656,260 +795,317 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor) verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor)
) )
Row(Modifier.fillMaxSize().padding(20.dp)) { val uiMode = GlobalUiMode.current
Row(Modifier.weight(0.66f).fillMaxHeight()) { var showOptions by remember(uiMode) { mutableStateOf(uiMode == UiMode.WIDE) }
if(entries.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { RowOrPanel(useRow = GlobalUiMode.current == UiMode.WIDE, Modifier.fillMaxSize()) {
Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) mainContent { isNarrow, toggleSide ->
} Box {
} Row(Modifier.padding(end = if (isNarrow) 25.dp else 0.dp)) {
else { if (entries.isEmpty()) {
Column { Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f).padding(35.dp)) {
Text("Measurements", style = MaterialTheme.typography.headlineSmall) Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
Row { }
Row(Modifier.weight(0.66f), verticalAlignment = Alignment.CenterVertically) { } else {
Text("View as: ", fontWeight = FontWeight.Bold) Column(Modifier.padding(35.dp)) {
Spacer(Modifier.width(5.dp)) Row {
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { Text("Measurements", style = MaterialTheme.typography.headlineSmall)
TimeUnit.entries.forEachIndexed { idx, it -> Spacer(Modifier.width(10.dp))
SegmentedButton( Text(
currentUnit == it, if (showOptions) "(hide options)" else "(show options)",
{ currentUnit = it }, Modifier.clickable { showOptions = !showOptions }.align(Alignment.Bottom),
shape = SegmentedButtonDefaults.itemShape(idx, TimeUnit.entries.size) color = linkColor,
) { textDecoration = TextDecoration.Underline
Text(it.disp) )
}
}
} }
}
Spacer(Modifier.width(50.dp)) if (showOptions) {
Column {
Row(Modifier.weight(0.33f), verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("Display: ", fontWeight = FontWeight.Bold) Text("View as: ", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(5.dp)) Spacer(Modifier.width(5.dp))
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
SegmentedButton(!displayWarmups, { displayWarmups = false }, TimeUnit.entries.forEachIndexed { idx, it ->
shape = SegmentedButtonDefaults.itemShape(0, 2) SegmentedButton(
) { currentUnit == it,
Text("(Steady-state) measurements") { currentUnit = it },
} shape = SegmentedButtonDefaults.itemShape(
idx,
SegmentedButton(displayWarmups, { displayWarmups = true }, TimeUnit.entries.size
shape = SegmentedButtonDefaults.itemShape(1, 2)
) {
Text("Warmup measurements")
}
}
}
}
Spacer(Modifier.height(5.dp))
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Filter by timestamp: ", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(5.dp))
RangeSlider(timeFilter, { timeFilter = it }, Modifier.fillMaxWidth(), valueRange = 0f..1f)
}
Text(timeFilterString, Modifier.align(Alignment.End))
}
Spacer(Modifier.height(15.dp))
XYGraph(
rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()),
rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()),
xAxisContent = AxisContent(
labels = { AxisLabel(format(it)) },
title = {},
style = style
),
yAxisContent = AxisContent(
labels = { AxisLabel(format(it)) },
title = {},
style = style
),
modifier = Modifier.weight(1f),
gridStyle = gridStyle
) {
renderableEntries.forEach { (data, color) ->
LinePlot(data, lineStyle = LineStyle(SolidColor(color), strokeWidth = 3.dp))
}
}
}
}
}
Spacer(Modifier.width(20.dp))
Column(Modifier.weight(0.33f).fillMaxHeight()) {
Row {
Column(Modifier.weight(0.5f)) {
Text("Versions", style = MaterialTheme.typography.headlineSmall)
Text("Click a version to toggle its visibility.", fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(10.dp))
if(versions.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
Text("No versions yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
}
}
else {
LazyColumn(Modifier.padding(start = 5.dp)) {
items(versions.toList().sortedBy { it.second.name }) { (_, lbl) ->
Box(Modifier.fillMaxWidth().clickable {
baseline = null
if(versionFilter.size == versions.size) {
versionFilter.clear()
versionFilter.add(lbl)
}
else {
versionFilter.toggle(lbl)
if(versionFilter.isEmpty()) versionFilter.addAll(versions.values)
}
}) {
Row {
Box(Modifier.weight(1f)) {
QuickVersion(lbl, lbl !in versionFilter)
if (lbl !in versionFilter) {
Box(Modifier.matchParentSize()) {
HorizontalDivider(
Modifier.fillMaxWidth().align(Alignment.Center),
thickness = 3.dp,
color = LocalContentColor.current
) )
) {
Text(it.disp)
} }
} }
} }
if(editable) { }
IconButton({ editingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) { Spacer(Modifier.height(5.dp))
Icon(Lucide.Pencil, "Edit version") Row(verticalAlignment = Alignment.CenterVertically) {
Text("Display: ", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(5.dp))
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
SegmentedButton(
!displayWarmups, { displayWarmups = false },
shape = SegmentedButtonDefaults.itemShape(0, 2)
) {
Text("(Steady-state) measurements")
} }
}
IconButton({ baseline = if(baseline == lbl) null else lbl }, SegmentedButton(
Modifier.align(Alignment.CenterVertically), enabled = lbl in versionFilter) { displayWarmups, { displayWarmups = true },
if(baseline == lbl) { shape = SegmentedButtonDefaults.itemShape(1, 2)
Surface(Modifier.padding(5.dp), color = MaterialTheme.colorScheme.inverseSurface, shape = MaterialTheme.shapes.large) { ) {
Icon(Lucide.RulerDimensionLine, "Use as baseline", Modifier.padding(5.dp)) Text("Warmup measurements")
}
} }
else Icon(Lucide.RulerDimensionLine, "Use as baseline")
} }
if(editable) { }
IconButton({ deletingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) { Spacer(Modifier.height(5.dp))
Icon(Lucide.Trash, "Delete version") Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Filter by timestamp: ", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(5.dp))
RangeSlider(
timeFilter,
{ timeFilter = it },
Modifier.fillMaxWidth(),
valueRange = 0f..1f
)
}
Text(timeFilterString, Modifier.align(Alignment.End))
}
}
}
Spacer(Modifier.height(15.dp))
if (!isNarrow || baselineComparison == null) {
XYGraph(
rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()),
rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()),
xAxisContent = AxisContent(
labels = { AxisLabel(format(it)) },
title = {},
style = style
),
yAxisContent = AxisContent(
labels = { AxisLabel(format(it)) },
title = {},
style = style
),
modifier = Modifier.weight(1f),
gridStyle = gridStyle
) {
renderableEntries.forEach { (data, color) ->
LinePlot(data, lineStyle = LineStyle(SolidColor(color), strokeWidth = 3.dp))
}
}
}
else baselineComparison?.let { blc ->
baselineGraph(baseline, baselineMax, style, gridStyle, blc, currentUnit)
}
}
}
}
if (isNarrow) {
ExpandableSidePanel(
Modifier.width(70.dp).fillMaxHeight().align(Alignment.CenterEnd),
toggleSide = toggleSide, elevation = 10.dp
)
}
}
sideContent(false) { isNarrow, toggleSide ->
val wrapper: @Composable (@Composable () -> Unit) -> Unit = if (isNarrow) { it ->
ExpandedSidePanel(Modifier.fillMaxWidth(0.8f), toggleSide = toggleSide) {
Box(Modifier.padding(10.dp)) { it() }
}
} else { it -> it() }
wrapper {
Column(Modifier.fillMaxHeight()) {
Row(Modifier.weight(0.33f)) {
Column(Modifier.weight(0.5f)) {
Text("Versions", style = MaterialTheme.typography.headlineSmall)
Text(
"Click a version to toggle its visibility.",
fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(alpha = 0.5f),
style = MaterialTheme.typography.bodySmall
)
Spacer(Modifier.height(10.dp))
if (versions.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
Text(
"No versions yet.",
Modifier.align(Alignment.Center),
fontStyle = FontStyle.Italic
)
}
} else {
LazyColumn(Modifier.padding(start = 5.dp)) {
items(versions.toList().sortedBy { it.second.name }) { (_, lbl) ->
Box(Modifier.fillMaxWidth().clickable {
baseline = null
if (versionFilter.size == versions.size) {
versionFilter.clear()
versionFilter.add(lbl)
} else {
versionFilter.toggle(lbl)
if (versionFilter.isEmpty()) versionFilter.addAll(versions.values)
}
}) {
Row {
Box(Modifier.weight(1f)) {
QuickVersion(lbl, lbl !in versionFilter)
if (lbl !in versionFilter) {
Box(Modifier.matchParentSize()) {
HorizontalDivider(
Modifier.fillMaxWidth().align(Alignment.Center),
thickness = 3.dp,
color = LocalContentColor.current
)
}
}
}
if (editable) {
IconButton(
{ editingVersion = lbl },
Modifier.align(Alignment.CenterVertically)
) {
Icon(Lucide.Pencil, "Edit version")
}
}
IconButton(
{ baseline = if (baseline == lbl) null else lbl },
Modifier.align(Alignment.CenterVertically),
enabled = lbl in versionFilter
) {
if (baseline == lbl) {
Surface(
Modifier.padding(5.dp),
color = MaterialTheme.colorScheme.inverseSurface,
shape = MaterialTheme.shapes.large
) {
Icon(
Lucide.RulerDimensionLine,
"Use as baseline",
Modifier.padding(5.dp)
)
}
} else Icon(Lucide.RulerDimensionLine, "Use as baseline")
}
if (editable) {
IconButton(
{ deletingVersion = lbl },
Modifier.align(Alignment.CenterVertically)
) {
Icon(Lucide.Trash, "Delete version")
}
}
}
} }
} }
} }
} }
} }
Spacer(Modifier.width(15.dp))
LazyColumn(Modifier.weight(0.5f)) {
item { Text("Inputs", style = MaterialTheme.typography.headlineSmall) }
item {
Text(
"Click an input to set or clear the filter.",
fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(0.5f),
style = MaterialTheme.typography.bodySmall
)
}
item { Spacer(Modifier.height(10.dp)) }
items((visibleInputs intersect inputFilter.toSet()).sorted()) {
Row(Modifier.fillMaxWidth().clickable {
if (inputFilter.size == allInputs.size) {
inputFilter.clear()
inputFilter.add(it)
} else if (inputFilter.size == 1) {
inputFilter.addAll(allInputs.toSet()); inputFilter.remove(it)
} else {
inputFilter.toggle(it)
if (inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
}
}) {
Text(
it,
Modifier.padding(start = 5.dp),
color = LocalContentColor.current.copy(alpha = if (it in enabledInputs) 1.0f else 0.9f)
)
if (it !in enabledInputs) Text(
" (no data)",
Modifier.align(Alignment.Bottom),
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(0.9f)
)
}
}
items((visibleInputs - inputFilter.toSet()).sorted()) {
Row(Modifier.fillMaxWidth().clickable {
if (inputFilter.size == allInputs.size) {
inputFilter.clear()
inputFilter.add(it)
} else {
inputFilter.toggle(it)
if (inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
}
}) {
Text(
it,
Modifier.padding(start = 5.dp),
color = LocalContentColor.current.copy(0.66f)
)
}
}
item { Spacer(Modifier.height(10.dp)) }
item { HorizontalDivider(Modifier.height(1.dp)) }
item { Spacer(Modifier.height(10.dp)) }
item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) }
item {
Text(
"Click an item to set or clear the filter.",
fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(0.5f),
style = MaterialTheme.typography.bodySmall
)
}
item { Spacer(Modifier.height(10.dp)) }
items((allHardware intersect enabledHardware.toSet()).toList()) {
Row(
Modifier.fillMaxWidth()
.clickable { filterHardware = if (filterHardware == null) it else null }) {
Text(it, Modifier.padding(start = 5.dp))
}
}
items((allHardware - enabledHardware.toSet()).toList()) {
Text(
it,
Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp),
color = LocalContentColor.current.copy(0.66f)
)
}
}
} }
}
}
Spacer(Modifier.width(15.dp))
LazyColumn(Modifier.weight(0.5f)) {
item { Text("Inputs", style = MaterialTheme.typography.headlineSmall) }
item {
Text("Click an input to set or clear the filter.", fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall)
}
item { Spacer(Modifier.height(10.dp)) } if (!isNarrow) {
baselineComparison?.let { blc ->
items((visibleInputs intersect inputFilter.toSet()).sorted()) { baselineGraph(baseline, baselineMax, style, gridStyle, blc, currentUnit)
Row(Modifier.fillMaxWidth().clickable {
if(inputFilter.size == allInputs.size) {
inputFilter.clear()
inputFilter.add(it)
}
else if(inputFilter.size == 1) {
inputFilter.addAll(allInputs.toSet()); inputFilter.remove(it)
}
else {
inputFilter.toggle(it)
if(inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
}
}) {
Text(it, Modifier.padding(start = 5.dp), color = LocalContentColor.current.copy(alpha = if(it in enabledInputs) 1.0f else 0.9f))
if(it !in enabledInputs) Text(" (no data)", Modifier.align(Alignment.Bottom),
style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(0.9f))
}
}
items((visibleInputs - inputFilter.toSet()).sorted()) {
Row(Modifier.fillMaxWidth().clickable {
if(inputFilter.size == allInputs.size) {
inputFilter.clear()
inputFilter.add(it)
}
else {
inputFilter.toggle(it)
if(inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
}
}) {
Text(it, Modifier.padding(start = 5.dp), color = LocalContentColor.current.copy(0.66f))
}
}
item { Spacer(Modifier.height(10.dp)) }
item { HorizontalDivider(Modifier.height(1.dp)) }
item { Spacer(Modifier.height(10.dp)) }
item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) }
item {
Text("Click an item to set or clear the filter.", fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall)
}
item { Spacer(Modifier.height(10.dp)) }
items((allHardware intersect enabledHardware.toSet()).toList()) {
Row(Modifier.fillMaxWidth().clickable { filterHardware = if(filterHardware == null) it else null }) {
Text(it, Modifier.padding(start = 5.dp))
}
}
items((allHardware - enabledHardware.toSet()).toList()) {
Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f))
}
}
}
Column(Modifier.weight(1f)) {
baselineComparison?.let { blc ->
Spacer(Modifier.height(10.dp))
HorizontalDivider(Modifier.height(1.dp))
Spacer(Modifier.height(10.dp))
Text("Speedup compared to ${baseline?.name ?: "<unknown version>"}", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(10.dp))
val (xs, ys, colors) = blc.unzip()
XYGraph(
xAxisModel = remember(blc) { CategoryAxisModel(xs) },
yAxisModel = rememberFloatLinearAxisModel(0f..baselineMax),
xAxisContent = AxisContent(
labels = { AxisLabel(it) },
title = {},
style = style
),
yAxisContent = AxisContent(
labels = { AxisLabel(format(it)) },
title = {},
style = style
),
modifier = Modifier.weight(1f),
gridStyle = gridStyle
) {
VerticalBarPlot(
xData = xs,
yData = ys,
bar = { x, _, _ -> DefaultBar(brush = SolidColor(colors[x]), modifier = Modifier.fillMaxWidth()) }
)
blc.forEach { (x, y, _, time) ->
XYAnnotation(Point(x, y), AnchorPoint.TopCenter) {
Text("${format(y)}x\n${format(time)} ${currentUnit.disp}", Modifier.padding(top = 5.dp))
} }
} }
} }
@@ -932,6 +1128,60 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
} }
} }
@Composable
fun ColumnScope.baselineGraph(baseline: ProjectVM.Version?, baselineMax: Float, style: AxisStyle, gridStyle: GridStyle,
blc: List<Tuple4<String, Float, Color, Float>>, currentUnit: TimeUnit) {
Column(Modifier.weight(0.66f)) {
Spacer(Modifier.height(10.dp))
HorizontalDivider(Modifier.height(1.dp))
Spacer(Modifier.height(10.dp))
Text(
"Speedup compared to ${baseline?.name ?: "<unknown version>"}",
style = MaterialTheme.typography.headlineSmall
)
Spacer(Modifier.height(10.dp))
val (xs, ys, colors) = blc.unzip()
XYGraph(
xAxisModel = remember(blc) { CategoryAxisModel(xs) },
yAxisModel = rememberFloatLinearAxisModel(0f..baselineMax),
xAxisContent = AxisContent(
labels = { AxisLabel(it) },
title = {},
style = style
),
yAxisContent = AxisContent(
labels = { AxisLabel(format(it)) },
title = {},
style = style
),
modifier = Modifier.weight(1f),
gridStyle = gridStyle
) {
VerticalBarPlot(
xData = xs,
yData = ys,
bar = { x, _, _ ->
DefaultBar(
brush = SolidColor(colors[x]),
modifier = Modifier.fillMaxWidth()
)
}
)
blc.forEach { (x, y, _, time) ->
XYAnnotation(Point(x, y), AnchorPoint.TopCenter) {
Text(
"${format(y)}x\n${format(time)} ${currentUnit.disp}",
Modifier.padding(top = 5.dp)
)
}
}
}
}
}
@Composable @Composable
fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit, onDelete: () -> Unit) { fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit, onDelete: () -> Unit) {
Dialog(onDismissRequest = onCancel) { Dialog(onDismissRequest = onCancel) {
@@ -939,7 +1189,7 @@ fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit,
Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) { Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) {
Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Text("Are you sure you want to delete $version?") Text("Are you sure you want to delete ${version.name}?")
Text("This will also remove all data associated with this version.") Text("This will also remove all data associated with this version.")
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete") CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
@@ -1,10 +1,75 @@
package com.jaytux.phoebench.clients package com.jaytux.phoebench.clients
import androidx.compose.ui.window.Window import androidx.compose.foundation.layout.Column
import androidx.compose.ui.window.application import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.SwingPanel
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import com.jaytux.phoebench.clients.theme.GlobalUiMode
import com.jaytux.phoebench.clients.theme.UiMode
import java.awt.GraphicsConfiguration
import java.awt.GraphicsEnvironment
import java.awt.Toolkit
import java.awt.event.ComponentEvent
import java.awt.event.ComponentListener
import javax.swing.JPanel
fun windowData(config: GraphicsConfiguration): Pair<DpSize, DpOffset> {
val bounds = config.bounds
val insets = Toolkit.getDefaultToolkit().getScreenInsets(config)
val availableWidthPx = bounds.width - insets.left - insets.right
val availableHeightPx = bounds.height - insets.top - insets.bottom
val density = config.defaultTransform.scaleX.toFloat()
val size = DpSize((availableWidthPx / density).dp, (availableHeightPx / density).dp)
val offset = DpOffset((insets.left / density).dp, (insets.top / density).dp)
return size to offset
}
fun main() = application { fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "PhoeBench") { val (winSize, winOffset) = windowData(GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration)
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,11 +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.DragAndDropEvent import androidx.compose.ui.draganddrop.*
import androidx.compose.ui.draganddrop.DragAndDropTransferAction
import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.draganddrop.DragAndDropTransferable
import androidx.compose.ui.draganddrop.awtTransferable
import androidx.compose.ui.platform.ClipEntry import 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
@@ -65,7 +61,7 @@ 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)
@@ -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()
}
} }
} }
@@ -13,8 +13,6 @@ 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.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import org.w3c.dom.DataTransfer import org.w3c.dom.DataTransfer
@@ -63,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)
@@ -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>
+1 -1
View File
@@ -18,7 +18,7 @@ val generatePartials = tasks.register<Exec>("generatePartials") {
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 }
@@ -0,0 +1,62 @@
package com.jaytux.phoebench.common
import io.ktor.client.plugins.websocket.*
import io.ktor.http.*
import io.ktor.util.reflect.*
import io.ktor.utils.io.*
import io.ktor.websocket.*
import kotlin.uuid.Uuid
sealed class CSERoute<TParams, TEvent: Any>(val path: String, val elevation: Elevation, private val _eventType: TypeInfo) {
open val pattern = path
protected open fun buildUrl(params: TParams): String = path
abstract fun extractParams(reqParams: Parameters): TParams?
suspend fun call(client: IClient, params: TParams, body: suspend (sender: suspend (TEvent) -> Unit) -> Unit): Either<ErrorResponse, Unit> {
val server = if(client.serverUrl.startsWith("http")) client.serverUrl.replace("http", "ws") else "ws://${client.serverUrl}"
var error: ErrorResponse? = null
try {
client.client.webSocket("$server${buildUrl(params)}", {}) {
try {
body { sendSerialized(it, _eventType) }
}
catch(e: CancellationException) {
closeReason.await()?.let {
if(it.code != CloseReason.Codes.NORMAL.code) error = ErrorResponse(it.message)
}
throw e
}
}
return Unit.value()
}
catch(e: CancellationException) {
return (error ?: ErrorResponse(e.message ?: "Unknown websocket error")).error()
}
catch(e: WebSocketException) {
return ErrorResponse("Could not set up websocket stream: ${e.message}").error()
}
catch(e: Exception) {
return ErrorResponse("WebSocket connection failed: ${e.message}").error()
}
}
class CSERoute1<T1, TEvent: Any>(path: String, elevation: Elevation, eventType: TypeInfo, val urlEncode: (T1) -> String, val urlDecode: (String?) -> T1?)
: CSERoute<T1, TEvent>(path, elevation, eventType)
{
override val pattern: String = "$path/{param}"
override fun buildUrl(params: T1): String = "$path/${urlEncode(params)}"
override fun extractParams(reqParams: Parameters): T1? = urlDecode(reqParams["param"])
}
companion object {
inline fun <T1, reified TEvent: Any> single(path: String, elevation: Elevation,
noinline urlEncode: (T1) -> String = { it.toString() }, noinline urlDecode: (String?) -> T1?
) = CSERoute1<T1, TEvent>(path, elevation, typeInfo<TEvent>(), urlEncode, urlDecode)
inline fun <reified TEvent: Any> uuid(path: String, elevation: Elevation) = single<Uuid, TEvent>(path, elevation) {
it?.let { p -> Uuid.parseOrNull(p) }
}
}
}
@@ -0,0 +1,8 @@
package com.jaytux.phoebench.common
enum class CloseReasons(val code: Short) {
NOT_AUTHORIZED(4001),
INVALID_REQUEST(4002),
CONFLICT(4003),
NOT_FOUND(4004)
}
@@ -1,6 +1,8 @@
package com.jaytux.phoebench.common package com.jaytux.phoebench.common
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.collections.fold
import kotlin.time.Instant
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@Serializable @Serializable
@@ -66,3 +68,80 @@ sealed class ProjectEvent {
@Serializable @Serializable
data class EntryDeleted(val id: Uuid, val benchmarkId: 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
}
@@ -29,6 +29,7 @@ object Routes {
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 { object Benchmark {
@@ -52,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" }
+2 -1
View File
@@ -7,7 +7,7 @@ plugins {
} }
group = "com.jaytux.phoebench" group = "com.jaytux.phoebench"
version = PhoebenchVersion(1, 1, 2) version = PhoebenchVersion(1, 2, 1, "patch1")
if((version as PhoebenchVersion) < (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") throw GradleException("Server version must be at least as high as protocol/common version")
@@ -56,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
@@ -4,10 +4,15 @@ 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.Meta
import com.jaytux.phoebench.server.db.Metas 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.*
@@ -21,8 +26,14 @@ 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() {
@@ -78,6 +89,10 @@ fun Application.module() {
install(SSE) {} 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."))
@@ -137,6 +152,7 @@ fun Application.module() {
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) postAuth(Routes.Benchmark.new, ProjectHandler::createBenchmark)
patchAuth(Routes.Benchmark.update, ProjectHandler::updateBenchmark) patchAuth(Routes.Benchmark.update, ProjectHandler::updateBenchmark)
@@ -149,13 +165,37 @@ fun Application.module() {
postAuth(Routes.Entry.new, ProjectHandler::createEntry) postAuth(Routes.Entry.new, ProjectHandler::createEntry)
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry) deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
sseAuth(Routes.SSE.home, { _, _ -> }) { _, _ -> Buses.homeBus } sseAuth(Routes.SSE.home, { _, _ -> }) { _, _, _ -> Buses.homeBus }
sseAdmin(Routes.SSE.admin) { _, _ -> Buses.adminBus } sseAdmin(Routes.SSE.admin) { _, _, _ -> Buses.adminBus }
sseAuth(Routes.SSE.projectSpecific, sseAuth(Routes.SSE.projectSpecific,
{ user, uuid -> { user, uuid ->
transaction { accessibleProject(user, uuid, false) } transaction { accessibleProject(user, uuid, false) }
} }
) { _, id -> Buses.projectBus(id) } ) { _, id, _ -> Buses.projectBus(id) }
sseAuth(Routes.SSE.monitor,
{ user, uuid ->
transaction { accessibleProject(user, uuid, true) }
}
) { _, id, sender ->
val bus = Buses.monitorBus(id)
val backlog = bus.onConnect()
sender(bus.bus.serializer, backlog)
bus.bus
}
cseAuth(Routes.CSE.monitor,
{ id, user -> transaction { accessibleProjectCSE(user, id, true) } },
ProjectHandler::monitorSetup, ProjectHandler::monitorTeardown) { _, _, bus, event ->
try {
when(event) {
is ClientMonitorEvent.ApplicationFinished -> bus.end(ServerMonitorEvent.ApplicationEnd(Clock.System.now(), event.exitCode))
is ClientMonitorEvent.Message -> bus.event(ServerMonitorEvent.Message(Clock.System.now(), event.msg, event.options, event.stream))
}
}
catch(e: IllegalStateException) {
throw RouteError.CSERouteError("Could not deliver message: ${e.message ?: "unknown error"}", CloseReasons.CONFLICT)
}
}
} }
get("{...}") { get("{...}") {
@@ -0,0 +1,35 @@
package com.jaytux.phoebench.server
class MutableBackLog<TStart, TEvent, TEnd> {
var start: TStart? = null
private set
private val _events = mutableListOf<TEvent>()
val events = _events.immutable()
var end: TEnd? = null
private set
fun registerStart(event: TStart) {
if(start != null) throw IllegalStateException("Start is already set.")
start = event
}
fun onEvent(event: TEvent) {
if(start == null) throw IllegalStateException("Start is not set yet.")
if(end != null) throw IllegalStateException("End is already set.")
_events += event
}
fun registerEnd(event: TEnd) {
if(start == null) throw IllegalStateException("Start is not set yet.")
if(end != null) throw IllegalStateException("End is already set.")
end = event
}
fun reset() {
start = null
_events.clear()
end = null
}
fun isRunning() = start != null && end == null
}
@@ -61,4 +61,32 @@ class SSEBus<T>(private val _containedType: KType, val serializer: KSerializer<T
map map
}.forEach { it.second.emit(Cancellation.error()) } }.forEach { it.second.emit(Cancellation.error()) }
} }
class MonitorSSEBus<TSuper, TStart : TSuper, TEvent : TSuper, TEnd: TSuper, TBacklog: TSuper>(
val bus: SSEBus<TSuper>, val backlog: MutableBackLog<TStart, TEvent, TEnd> = MutableBackLog(),
val mkBacklog: (start: TStart?, events: List<TEvent>, end: TEnd?) -> TBacklog
) {
val start: TStart? get() = backlog.start
val events: List<TEvent> get() = backlog.events
val end: TEnd? get() = backlog.end
suspend fun start(start: TStart) {
backlog.registerStart(start)
bus.sendAll(start)
}
suspend fun event(event: TEvent) {
backlog.onEvent(event)
bus.sendAll(event)
}
suspend fun end(end: TEnd) {
backlog.registerEnd(end)
bus.sendAll(end)
}
suspend fun onConnect() = mkBacklog(start, events, end)
fun isRunning() = backlog.isRunning()
}
} }
@@ -24,3 +24,5 @@ fun nowPlus(time: Int, unit: DateTimeUnit): Instant {
} }
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)
fun <T> MutableList<T>.immutable(): List<T> = this
@@ -4,18 +4,19 @@ import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrapped
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAdmin import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAdmin
import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAuth import com.jaytux.phoebench.server.handlers.RouteError.Companion.wrappedAuth
import com.jaytux.phoebench.common.ApiRoute import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.CSERoute
import com.jaytux.phoebench.common.CloseReasons
import com.jaytux.phoebench.common.Elevation import com.jaytux.phoebench.common.Elevation
import com.jaytux.phoebench.common.EmptyRequest import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.ErrorResponse import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.SSERoute import com.jaytux.phoebench.common.SSERoute
import com.jaytux.phoebench.common.foldSuspend import com.jaytux.phoebench.common.foldSuspend
import com.jaytux.phoebench.server.Auth.setup
import com.jaytux.phoebench.server.SSEBus import com.jaytux.phoebench.server.SSEBus
import com.jaytux.phoebench.server.db.User import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.RouteError
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.serialization.deserialize
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.jwt.JWTPrincipal import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal import io.ktor.server.auth.principal
@@ -31,10 +32,15 @@ import io.ktor.server.routing.delete
import io.ktor.server.routing.patch import io.ktor.server.routing.patch
import io.ktor.server.sse.heartbeat import io.ktor.server.sse.heartbeat
import io.ktor.server.sse.sse import io.ktor.server.sse.sse
import io.ktor.server.websocket.webSocket
import io.ktor.sse.ServerSentEvent import io.ktor.sse.ServerSentEvent
import io.ktor.util.reflect.typeInfo import io.ktor.util.reflect.typeInfo
import io.ktor.utils.io.CancellationException import io.ktor.utils.io.CancellationException
import io.ktor.websocket.CloseReason
import io.ktor.websocket.close
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -165,7 +171,7 @@ inline fun <reified TReq: Any, reified TRes: Any> Route.patchAdmin(api: ApiRoute
inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrapSSE( inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrapSSE(
api: SSERoute<TParams, TEvent>, noinline extra: suspend (ApplicationCall, TParams) -> TInter, api: SSERoute<TParams, TEvent>, noinline extra: suspend (ApplicationCall, TParams) -> TInter,
noinline prepare: suspend (TInter, TParams) -> SSEBus<TEvent>, noinline prepare: suspend (TInter, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>,
noinline extract: suspend (SSEBus<TEvent>, TInter, TParams) -> SharedFlow<TFlow>, noinline extract: suspend (SSEBus<TEvent>, TInter, TParams) -> SharedFlow<TFlow>,
noinline handler: suspend (TFlow, sender: suspend (TEvent) -> Unit) -> Unit, noinline handler: suspend (TFlow, sender: suspend (TEvent) -> Unit) -> Unit,
noinline onCancel: suspend (SSEBus<TEvent>, TInter, TParams, CancellationException) -> Unit noinline onCancel: suspend (SSEBus<TEvent>, TInter, TParams, CancellationException) -> Unit
@@ -182,11 +188,14 @@ inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrap
) )
val inter = extra(call, params) val inter = extra(call, params)
val bus = prepare(inter, params) val bus = prepare(inter, params) { serializer, event -> send(ServerSentEvent(data = Json.encodeToString(serializer, event))) }
val stream = extract(bus, inter, params) val stream = extract(bus, inter, params)
try { try {
stream.collect { stream.collect {
handler(it) { ev -> send(ServerSentEvent(data = Json.encodeToString(bus.serializer, ev))) } handler(it) { ev ->
println("[SSE ${api.pattern}]: sending event!")
send(ServerSentEvent(data = Json.encodeToString(bus.serializer, ev)))
}
} }
} }
catch(e: CancellationException) { catch(e: CancellationException) {
@@ -214,7 +223,7 @@ inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrap
inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE( inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
api: SSERoute<TParams, TEvent>, api: SSERoute<TParams, TEvent>,
noinline verifyUser: suspend (User, TParams) -> Unit, noinline verifyUser: suspend (User, TParams) -> Unit,
noinline prepare: suspend (User, TParams) -> SSEBus<TEvent> noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
) = wrapSSE(api, ) = wrapSSE(api,
extra = { call, params -> extra = { call, params ->
val principal = call.principal<JWTPrincipal>() val principal = call.principal<JWTPrincipal>()
@@ -239,23 +248,123 @@ inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
onCancel = { bus, user, _, _ -> bus.disconnect(user.id.value) } onCancel = { bus, user, _, _ -> bus.disconnect(user.id.value) }
) )
inline fun <reified TParams: Any, reified TEvent: Any> Route.sse(api: SSERoute<TParams, TEvent>, noinline setup: suspend (TParams) -> SSEBus<TEvent>) = inline fun <reified TParams: Any, reified TEvent: Any> Route.sse(api: SSERoute<TParams, TEvent>, noinline prepare: suspend (TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>) {
wrapSSE(api, if(api.elevation != Elevation.UN_AUTH) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
wrapSSE(
api,
extra = { _, _ -> }, extra = { _, _ -> },
prepare = { _, params -> setup(params) }, prepare = { _, params, sender -> prepare(params, sender) },
extract = { bus, _, _ -> bus.unRegistered() }, extract = { bus, _, _ -> bus.unRegistered() },
handler = { it, sender -> sender(it) }, handler = { it, sender -> sender(it) },
onCancel = { _, _, _, _ -> } onCancel = { _, _, _, _ -> }
) )
}
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAuth(api: SSERoute<TParams, TEvent>, inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAuth(api: SSERoute<TParams, TEvent>,
noinline verifyUser: suspend (User, TParams) -> Unit, noinline setup: suspend (User, TParams) -> SSEBus<TEvent> noinline verifyUser: suspend (User, TParams) -> Unit, noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
) = wrapAuthSSE(api, verifyUser, setup) ) {
if(api.elevation != Elevation.AUTH) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthSSE(api, verifyUser, prepare)
}
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAdmin(api: SSERoute<TParams, TEvent>, inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAdmin(api: SSERoute<TParams, TEvent>,
noinline setup: suspend (User, TParams) -> SSEBus<TEvent> noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
) = wrapAuthSSE(api, { user, _ -> ) {
if(!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 onClose: suspend (TParams, TInter, TExtra) -> Unit,
noinline handler: suspend (TParams, TInter, TExtra, TEvent) -> Unit
) {
webSocket(api.pattern) {
try {
val params = api.extractParams(call.parameters) ?:
throw RouteError.CSERouteError("Missing or malformed parameters for CSE ${api.pattern}", CloseReasons.INVALID_REQUEST)
val inter = extra(call, params)
val converter = KotlinxWebsocketSerializationConverter(Json)
val extra = setup(params, inter)
try {
for (frame in incoming) {
handler(params, inter, extra, converter.deserialize<TEvent>(frame))
}
}
finally {
onClose(params, inter, extra)
}
}
catch(e: RouteError.CSERouteError) {
close(CloseReason(e.code, e.message ?: "Unknown error"))
}
catch(e: Exception) {
close(CloseReason(CloseReason.Codes.INTERNAL_ERROR, e.message ?: "Unknown error"))
}
} }
}, setup) }
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.wrapAuthCSE(
api: CSERoute<TParams, TEvent>,
noinline verifyUser: suspend (TParams, User) -> Unit,
noinline setup: suspend (TParams, User) -> TExtra,
noinline onClose: suspend (TParams, User, TExtra) -> Unit,
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
) = wrapCSE<TParams, TEvent, User, TExtra>(
api = api,
extra = { call, params ->
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.getClaim(com.jaytux.phoebench.common.Auth.JWT_CLAIM)?.asString()
?: throw RouteError.CSERouteError("Missing user claim", CloseReasons.NOT_AUTHORIZED)
val user = transaction {
User.findById(Uuid.parse(userId)) ?: throw RouteError.CSERouteError(
"User not found",
CloseReasons.NOT_AUTHORIZED
)
}
verifyUser(params, user)
user
},
setup = setup,
onClose = onClose,
handler = handler
)
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cse(
api: CSERoute<TParams, TEvent>,
noinline setup: suspend (TParams) -> TExtra,
noinline onClose: suspend (TParams, TExtra) -> Unit,
noinline handler: suspend (TParams, TExtra, TEvent) -> Unit
) {
if(api.elevation != Elevation.UN_AUTH) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
wrapCSE(api, { _, _ -> }, { it, _ -> setup(it) }, { p, _, extra -> onClose(p, extra) }) { params, _, extra, event -> handler(params, extra, event) }
}
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cseAuth(
api: CSERoute<TParams, TEvent>, noinline verifyUser: suspend (TParams, User) -> Unit,
noinline setup: suspend (TParams, User) -> TExtra, noinline onClose: suspend (TParams, User, TExtra) -> Unit,
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
) {
if(api.elevation != Elevation.AUTH) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthCSE(api, verifyUser, setup, onClose, handler)
}
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cseAdmin(
api: CSERoute<TParams, TEvent>, noinline setup: suspend (TParams, User) -> TExtra,
noinline onClose: suspend (TParams, User, TExtra) -> Unit,
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
) {
if(api.elevation != Elevation.ADMIN) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthCSE(api, { _, user ->
if(!user.isAdmin) {
throw RouteError.CSERouteError("Admin access required", CloseReasons.NOT_AUTHORIZED)
}
}, setup, onClose, handler)
}
@@ -3,6 +3,8 @@ package com.jaytux.phoebench.server.handlers
import com.jaytux.phoebench.common.BenchmarkRequest import com.jaytux.phoebench.common.BenchmarkRequest
import com.jaytux.phoebench.common.BenchmarkResponse import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.BenchmarkSummary 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
@@ -19,7 +21,9 @@ 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.app
import com.jaytux.phoebench.server.db.Benchmark import com.jaytux.phoebench.server.db.Benchmark
import com.jaytux.phoebench.server.db.Benchmarks import com.jaytux.phoebench.server.db.Benchmarks
@@ -43,6 +47,7 @@ import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.notInList import org.jetbrains.exposed.v1.core.notInList
import org.jetbrains.exposed.v1.jdbc.deleteWhere 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 {
@@ -68,6 +73,11 @@ object ProjectHandler {
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> { fun Transaction.accessibleBenchmark(user: User, id: Uuid, forEditing: Boolean): Pair<Project, Benchmark> {
val bench = Benchmark.findById(id) ?: throw RouteError("Invalid benchmark ID.", HttpStatusCode.NotFound) val bench = Benchmark.findById(id) ?: throw RouteError("Invalid benchmark ID.", HttpStatusCode.NotFound)
return bench.isAccessible(user, forEditing) return bench.isAccessible(user, forEditing)
@@ -381,4 +391,34 @@ object ProjectHandler {
return home(user, EmptyRequest()) return home(user, EmptyRequest())
} }
suspend fun monitorSetup(id: Uuid, user: User) = Buses.monitorBus(id).also {
try {
if(!it.isRunning()) it.backlog.reset()
it.start(ServerMonitorEvent.ApplicationStart(Clock.System.now()))
}
catch(e: IllegalStateException) {
throw RouteError.CSERouteError("Monitor may already be running. Please refrain from starting a second trace.", CloseReasons.CONFLICT)
}
}
suspend fun monitorTeardown(
id: Uuid, user: User,
bus: SSEBus.MonitorSSEBus<ServerMonitorEvent, ServerMonitorEvent.ApplicationStart, ServerMonitorEvent.Message,
ServerMonitorEvent.ApplicationEnd, ServerMonitorEvent.Backlog>
) {
bus.end(ServerMonitorEvent.ApplicationEnd(Clock.System.now(), -1))
}
fun deleteMonitor(user: User, req: Uuid) = transaction {
checkMigration(user)
accessibleProject(user, req, true)
val bus = Buses.monitorBus(req)
if(bus.isRunning()) throw RouteError("Monitor is still running", HttpStatusCode.Conflict)
bus.backlog.reset()
ServerScope.launch {
bus.bus.sendAll(ServerMonitorEvent.Cleared)
}
success(EmptyResponse())
}
} }
@@ -1,6 +1,7 @@
package com.jaytux.phoebench.server.handlers 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)
}
} }