7 Commits
Author SHA1 Message Date
jaytux 563ee98d03 ANSI update 2026-09-01 14:58:17 +02:00
jaytux 7f92df1ca5 CLI: fix keyring backend bug 2026-09-01 14:09:37 +02:00
jaytux 768bb0c650 Fix window resize (desktop) 2026-09-01 11:54:56 +02:00
jaytux 85902620da Script monitoring 2026-09-01 11:47:10 +02:00
jaytux 8da391b6ce Web UI: width detection 2026-08-30 21:24:56 +02:00
jaytux bc9861900e UI clients: fix narrow vs wide UI 2026-08-30 21:11:18 +02:00
jaytux 1d10802140 CLI: formatting 2026-08-27 15:23:07 +02:00
45 changed files with 1491 additions and 346 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/
mkdir -p artifacts/wasm
cp clients/compose/build/kotlin-webpack/wasmJs/productionExecutable/* clients/compose/src/wasmJsMain/resources/* artifacts/wasm
(cd artifacts/wasm && zip phoebench-wasm.zip -r ./*)
cp -r clients/compose/build/kotlin-webpack/wasmJs/productionExecutable/* clients/compose/build/processedResources/wasmJs/main/* artifacts/wasm
(cd artifacts/wasm && rm config.json && zip phoebench-wasm.zip -r ./*)
mv artifacts/wasm/phoebench-wasm.zip artifacts/
rm -fr artifacts/wasm
+1 -1
View File
@@ -14,4 +14,4 @@ repositories {
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
}
}
+27 -1
View File
@@ -5,6 +5,7 @@ plugins {
application
alias(libs.plugins.serialization)
alias(libs.plugins.shadow)
alias(libs.plugins.buildconfig)
}
group = "com.jaytux.phoebench"
@@ -28,12 +29,15 @@ val generateVersion = tasks.register<Task>("serverVersion") {
}
}
val isDebug = envValue("PHOEBENCH_BUILD_RELEASE") == null
dependencies {
implementation(kotlin("stdlib"))
implementation(libs.clikt)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.websocket)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization)
implementation(project(":common"))
@@ -42,6 +46,7 @@ dependencies {
implementation(libs.slf4j.simple)
implementation(libs.java.keystore)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.process)
}
application {
@@ -75,5 +80,26 @@ kotlin {
}
tasks.withType<ShadowJar> {
archiveFileName = "phoebench-cli.jar"
val archive = if(isDebug) "phoebench-cli-debug.jar" else "phoebench-cli.jar"
println("Using archive '$archive'")
archiveFileName = archive
}
buildConfig {
generateAtSync = false
useKotlinOutput { internalVisibility = false }
println("Source sets: ${kotlin.sourceSets.toList().map { it.name }}")
className("PersistenceConstants")
packageName("com.jaytux.phoebench.clients.cli")
if(isDebug) {
println("Using debug CLI service")
buildConfigField("SERVICE", "com.jaytux.phoebench.cli.debug")
}
else {
println("Using release CLI service")
buildConfigField("SERVICE", "com.jaytux.phoebench.cli")
}
}
@@ -6,16 +6,21 @@ import kotlin.system.exitProcess
import kotlin.uuid.Uuid
object AuthHandlers {
suspend fun checkServer() =
Client.callRoute(Routes.handshake, EmptyRequest()).bind { handshake ->
if(handshake.version != ProtocolVersion.VERSION) {
val server = Client.getServer() ?: "<unknown server ULR>"
Client.onClearServer()
ErrorResponse("Cannot use $server: protocol version mismatch (server: ${handshake.version}, expected: ${ProtocolVersion.VERSION})").error()
}
Unit.value()
}
fun serverPrompt(server: String?): Either<ErrorResponse, Unit> {
val useServer = server.maybePrompt("server") { it }
Client.onSelectServer(useServer)
return runBlocking {
Client.callRoute(Routes.handshake, EmptyRequest()).bind { handshake ->
if (handshake.version != ProtocolVersion.VERSION) {
Client.onClearServer()
ErrorResponse("Mismatched protocol version (server: ${handshake.version}, client: ${ProtocolVersion.VERSION})").error()
} else Unit.value()
}
checkServer().bind { Client.onSelectServer(useServer).value() }
}
}
@@ -1,6 +1,8 @@
package com.jaytux.phoebench.clients.cli
import com.github.ajalt.clikt.core.*
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.multiple
import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions
import com.github.ajalt.clikt.parameters.groups.single
import com.github.ajalt.clikt.parameters.options.*
@@ -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")
class Version : CliktCommand(name = "version") {
override fun run() {
@@ -1,6 +1,7 @@
package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.CSERoute
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.IClient
@@ -9,15 +10,31 @@ import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.TokenResponse
import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.foldSuspend
import com.jaytux.phoebench.common.value
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.ResponseException
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.websocket.WebSocketException
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.sendSerialized
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.url
import io.ktor.http.HttpMethod
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.serialization.kotlinx.json.json
import io.ktor.util.reflect.TypeInfo
import io.ktor.util.reflect.typeInfo
import io.ktor.utils.io.CancellationException
import io.ktor.websocket.CloseReason
import kotlinx.coroutines.asExecutor
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import java.net.URL
import kotlin.uuid.Uuid
object Client {
@@ -68,6 +85,10 @@ object Client {
}
}
}
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
}
init {
@@ -91,6 +112,19 @@ object Client {
suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> =
callRoute(_client, route, body)
suspend fun <TParams: Any, TEvent: Any> callCSE(
route: CSERoute<TParams, TEvent>, params: TParams,
body: suspend (sender: suspend (TEvent) -> Unit) -> Unit
): Either<ErrorResponse, Unit> {
try {
val client = IClient.Default(_client, _server ?: throw IllegalStateException("No server URL set."))
return route.call(client, params, body)
}
catch(e: CancellationException) {
return ErrorResponse("Event stream disconnected.").error()
}
}
fun onLogin(tokens: TokenResponse) {
_refreshToken = tokens.refresh
_accessToken = tokens.access
@@ -0,0 +1,83 @@
package com.jaytux.phoebench.clients.cli
import com.github.pgreze.process.Redirect
import com.github.pgreze.process.process
import com.jaytux.phoebench.clients.cli.ProjectHandlers.toId
import com.jaytux.phoebench.common.ANSI
import com.jaytux.phoebench.common.ClientMonitorEvent
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.Stream
import com.jaytux.phoebench.common.bind
import com.jaytux.phoebench.common.fold
import io.ktor.http.HttpMethod
import io.ktor.utils.io.CancellationException
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Instant
object MonitorHandler {
private fun filterAnsi(line: String): Pair<String, List<ANSI>> {
val builder = StringBuilder()
val codes = mutableSetOf<ANSI>()
var remaining = line
while(remaining.isNotEmpty()) {
val next = remaining.indexOf("\u001B[")
if(next < 0) {
builder.append(remaining)
remaining = ""
}
else {
builder.append(remaining.substring(0, next))
val end = remaining.indexOf('m', startIndex = next + 2)
if(end == -1) return (line to listOf()) // invalid...
codes += remaining.substring(next + 2, end).split(';').mapNotNull {
it.toIntOrNull()?.let { i -> ANSI.ansiMapping[i] }
}
remaining = remaining.substring(end + 1)
}
}
return builder.toString() to codes.toList()
}
fun monitor(find: CLI.Commands.Project.IProjectIdentification?, command: String, args: List<String>) {
val project = ProjectHandlers.ensureProjectIdentification(find)
val combinedCommand = arrayOf(command, *args.toTypedArray())
tryAuthenticated {
project.toId().bind { id ->
Client.callCSE(Routes.CSE.monitor, id) { sender ->
try {
val res = process(
*combinedCommand,
stdin = null,
stdout = Redirect.Consume { flow ->
flow.collect { line ->
val (updLine, options) = filterAnsi(line)
sender(ClientMonitorEvent.Message(msg = updLine, stream = Stream.STDOUT, options = options))
println(line)
}
},
stderr = Redirect.Consume { flow ->
flow.collect { line ->
val (updLine, options) = filterAnsi(line)
sender(ClientMonitorEvent.Message(msg = updLine, stream = Stream.STDERR, options = options))
System.err.println(line)
}
}
)
sender(ClientMonitorEvent.ApplicationFinished(res.resultCode))
}
catch(e: CancellationException) {
throw e
}
catch(e: Exception) {
System.err.println("Launch failed: ${e.message} (${e::class.simpleName})")
}
}
}
}.fold({
System.err.println("Failed to run: ${it.msg}")
}) {}
}
}
@@ -2,6 +2,7 @@ package com.jaytux.phoebench.clients.cli
import com.github.javakeyring.BackendNotSupportedException
import com.github.javakeyring.Keyring
import com.github.javakeyring.PasswordAccessException
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
@@ -10,7 +11,7 @@ import kotlin.uuid.Uuid
object PersistentStorage {
private val json = Json
const val SERVICE = "com.jaytux.phoebench.cli"
const val SERVICE = PersistenceConstants.SERVICE
private var _backend: IBackend = KeyringBackend
interface IBackend {
@@ -22,7 +23,11 @@ object PersistentStorage {
private object KeyringBackend : IBackend {
override fun read(key: String): String? = try {
Keyring.create().use { it.getPassword(SERVICE, key) }
} catch(e: Exception) {
}
catch(e: PasswordAccessException) {
return null
}
catch(e: BackendNotSupportedException) {
_backend = PreferencesBackend
_backend.read(key)
}
@@ -78,7 +78,7 @@ object ProjectHandlers {
println("\n${it.benchmarks.size} benchmarks:")
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 ->
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})")
+1 -18
View File
@@ -13,7 +13,7 @@ plugins {
alias(libs.plugins.buildconfig)
}
version = PhoebenchVersion(1, 1, 1)
version = rootProject.version as PhoebenchVersion
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("UI Clients version must be at least as high as protocol/common version")
@@ -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 {
generateAtSync = false
useKotlinOutput { internalVisibility = false }
@@ -3,7 +3,10 @@ package com.jaytux.phoebench.clients
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.dp
import com.jaytux.phoebench.clients.ui.RootView
@Composable
@@ -4,8 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.platform.ClipEntry
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.*
import kotlinx.serialization.KSerializer
import kotlinx.serialization.serializer
import kotlin.uuid.Uuid
@@ -1,9 +1,15 @@
package com.jaytux.phoebench.clients
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.jaytux.phoebench.common.Either
@@ -18,9 +24,10 @@ import kotlinx.datetime.format.MonthNames
import kotlinx.datetime.format.Padding
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.Font
import phoebench.clients.compose.generated.resources.*
import kotlin.math.absoluteValue
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.random.Random
import kotlin.random.nextInt
import kotlin.time.Clock
@@ -145,3 +152,15 @@ fun List<Float>.geomean(): Float {
for(value in this) prod *= value
return prod.pow(1.0f / size.toFloat())
}
@Composable
fun TextStyle.makeMonospaced(): TextStyle {
val family = FontFamily(
Font(Res.font.JetBrainsMono_Regular, weight = FontWeight.Normal, style = FontStyle.Normal),
Font(Res.font.JetBrainsMono_Bold, weight = FontWeight.Bold, style = FontStyle.Normal),
Font(Res.font.JetBrainsMono_Italic, weight = FontWeight.Normal, style = FontStyle.Italic),
Font(Res.font.JetBrainsMono_BoldItalic, weight = FontWeight.Bold, style = FontStyle.Italic)
)
return copy(fontFamily = family)
}
@@ -38,6 +38,8 @@ interface IProjectRepo {
unit: TimeUnit, input: String, hardware: String): Either<ErrorResponse, EntryResponse>
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
suspend fun deleteMonitorLogs(): Either<ErrorResponse, Unit>
companion object {
class Default(private val _client: Client, private val _projectId: Uuid) : IProjectRepo {
override suspend fun get(): Either<ErrorResponse, ProjectResponse> =
@@ -74,6 +76,9 @@ interface IProjectRepo {
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Entry.delete, id).ignoreValue()
override suspend fun deleteMonitorLogs(): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.rmMonitor, _projectId).ignoreValue()
}
fun default(client: Client, projectId: Uuid) = Default(client, projectId)
@@ -7,12 +7,14 @@ import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.ServerMonitorEvent
import kotlin.uuid.Uuid
interface ISSERepo {
suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectMonitor(id: Uuid, onEvent: suspend (Either<ErrorResponse, ServerMonitorEvent>) -> Unit): Either<ErrorResponse, Unit>
companion object {
class Default(private val _client: Client) : ISSERepo {
@@ -24,6 +26,9 @@ interface ISSERepo {
override suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit> =
_client.callSSE(Routes.SSE.projectSpecific, id, onEvent)
override suspend fun connectMonitor(id: Uuid, onEvent: suspend (Either<ErrorResponse, ServerMonitorEvent>) -> Unit): Either<ErrorResponse, Unit> =
_client.callSSE(Routes.SSE.monitor, id, onEvent)
}
fun default(client: Client) = Default(client)
@@ -1,5 +1,6 @@
package com.jaytux.phoebench.clients.data
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
@@ -18,7 +19,9 @@ import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ServerMonitorEvent
import com.jaytux.phoebench.common.TimeUnit
import com.jaytux.phoebench.common.fold
import kotlinx.coroutines.Job
import kotlin.time.Clock
import kotlin.time.Instant
@@ -87,6 +90,7 @@ class ProjectVM(
private val _currentBenchmark = mutableStateOf(0)
private val _inputs = mutableStateOf(setOf<String>())
private val _hardware = mutableStateOf(setOf<String>())
private val _monitorMessages = mutableStateOf<List<ServerMonitorEvent.ITextEvent>?>(null)
val name = _name.immutable()
val owner = _owner.immutable()
@@ -97,8 +101,10 @@ class ProjectVM(
val currentBenchmark = _currentBenchmark.immutable()
val inputs = _inputs.immutable()
val hardware = _hardware.immutable()
val monitorMessages = _monitorMessages.immutable()
private var _job: Job? = null
private var _monitorJob: Job? = null
init {
_job = withScope {
@@ -133,7 +139,10 @@ class ProjectVM(
ProjectEvent.Deleted -> back()
is ProjectEvent.EntryDeleted -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmarkId }) { it.remove(event.id) }
is ProjectEvent.VersionChanged -> _labels.value += (event.label.id to Version.fromResponse(event.label))
is ProjectEvent.VersionDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id }
is ProjectEvent.VersionDeleted -> {
_labels.value = _labels.value.filter { it.value.id != event.id }
_benchmarks.value = _benchmarks.value.map { it.copy(entries = it.entries.filter { e -> e.label.id != event.id }) }
}
is ProjectEvent.NewEntry -> {
_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 })) }
@@ -218,7 +227,7 @@ class ProjectVM(
val (updated, idx) = _benchmarks.value.insortIdx(
Benchmark.fromResponse(bench, _labels.value, { i -> _inputs.value += i }, { hw -> _hardware.value += hw })
) { it.name }
_currentBenchmark.value?.let { curr ->
_currentBenchmark.value.let { curr ->
if(idx != null) {
if(idx < curr) _currentBenchmark.value = curr + 1
}
@@ -276,4 +285,37 @@ class ProjectVM(
fun selectBenchmark(id: Uuid) {
_currentBenchmark.value = maxOf(_benchmarks.value.indexOfFirst { it.id == id }, 0)
}
fun openMonitor() {
if(_monitorJob != null) return
_monitorMessages.value = listOf()
_monitorJob = withScope {
_sseRepo.connectMonitor(_id) { ev ->
ev.snackOr {
when(it) {
is ServerMonitorEvent.Backlog -> {
val res = ArrayList<ServerMonitorEvent.ITextEvent>(it.messages.size + 2)
it.start?.let { start -> res += start }
res.addAll(it.messages)
it.end?.let { end -> res += end }
_monitorMessages.value = res
}
ServerMonitorEvent.Cleared -> _monitorMessages.value = listOf()
is ServerMonitorEvent.ApplicationEnd, is ServerMonitorEvent.ApplicationStart, is ServerMonitorEvent.Message ->
_monitorMessages.value = (_monitorMessages.value ?: listOf()) + it
}
}
}
}
}
fun closeMonitor() {
_monitorJob?.cancel()
_monitorJob = null
_monitorMessages.value = null
}
fun clearMonitor() = withScope {
_repo.deleteMonitorLogs().snackOnError()
}
}
@@ -1,5 +1,24 @@
package com.jaytux.phoebench.clients.theme
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
val linkColor = Color(0xFF64B5F6)
enum class UiMode(val minWidth: Dp) {
MOBILE(0.dp),
NARROW(0.dp),
WIDE(2000.dp);
companion object {
fun fromDp(width: Dp) = when {
width < NARROW.minWidth -> MOBILE
width < WIDE.minWidth -> NARROW
else -> WIDE
}
}
}
val GlobalUiMode = compositionLocalOf { UiMode.MOBILE }
@@ -25,6 +25,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -237,10 +238,12 @@ fun SignupView(server: String?, error: String?, onSwitchLogin: () -> Unit, onSig
@Composable
fun AuthenticatedRoot() {
val snacks = SnackProvider.get()
val auth = AuthProvider.get()
val state = remember { SnackbarHostState() }
val vm = viewModel { HomeVM() }
var loggingOut by remember { mutableStateOf(false) }
var currentProject by remember { mutableStateOf<Uuid?>(null) }
val server by auth.server
LaunchedEffect(snacks) {
snacks.snacks.collect { state.showSnackbar(it) }
@@ -254,7 +257,12 @@ fun AuthenticatedRoot() {
Scaffold(
topBar = {
TopAppBar(
title = { Text("PhoeBench v${ClientVersion.VERSION}", style = MaterialTheme.typography.headlineLarge) },
title = {
Column {
Text("PhoeBench v${ClientVersion.VERSION}", style = MaterialTheme.typography.headlineLarge)
server?.let { Text("Connected to $it", style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(0.6f)) }
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
@@ -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
)
@@ -18,17 +18,23 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.onGloballyPositioned
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.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.composables.icons.lucide.*
import com.jaytux.phoebench.clients.*
import com.jaytux.phoebench.clients.data.ProjectVM
import com.jaytux.phoebench.clients.data.mutableStateSetFrom
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.kborowy.colorpicker.KolorPicker
import io.github.koalaplot.core.bar.DefaultBar
@@ -53,6 +59,7 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
val versions by vm.versions
val benchmarks by vm.benchmarks
val currentBenchmark by vm.currentBenchmark
val monitor by vm.monitorMessages
var editing by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(false) }
@@ -69,8 +76,12 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
IconButton({ deleting = true }) {
Icon(Lucide.Trash, "Delete project")
}
IconButton({ vm.openMonitor() }) {
Icon(Lucide.SquareTerminal, "Open monitor")
}
}
}
owner?.let { Text("${if(public) "Public" else "Private"} project by user $it") }
Spacer(Modifier.height(15.dp))
benchmarks.getOrNull(currentBenchmark)?.let { curr ->
@@ -108,6 +119,78 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
vm.mkBenchmark(it)
addingBenchmark = false
}
monitor?.let { MonitorDialog(name ?: "Unnamed Project", it, vm::closeMonitor, vm::clearMonitor) }
}
@Composable
fun MonitorMessage(msg: ServerMonitorEvent.ITextEvent) = when(msg) {
is ServerMonitorEvent.ApplicationEnd -> Text("[${msg.time.fmt()}] Application finished with exit code ${msg.exitCode}.", fontStyle = FontStyle.Italic)
is ServerMonitorEvent.ApplicationStart -> Text("[${msg.time.fmt()}] Application started.", fontStyle = FontStyle.Italic)
is ServerMonitorEvent.Message -> {
val ansi = ANSI.unpack(msg.options).toSet()
val weight = if(ANSI.BOLD in ansi) FontWeight.Bold else null
val decoration = if(ANSI.UNDERLINE in ansi) TextDecoration.Underline else null
val color = when {
ANSI.FG_RED in ansi -> Color.Red
ANSI.FG_GREEN in ansi -> Color.Green
ANSI.FG_YELLOW in ansi -> Color.Yellow
ANSI.FG_BLUE in ansi -> Color.Blue
ANSI.FG_GRAY in ansi -> Color.Gray
else -> LocalContentColor.current
}
val background = when {
ANSI.BG_RED in ansi -> Color.Red
ANSI.BG_GREEN in ansi -> Color.Green
ANSI.BG_YELLOW in ansi -> Color.Yellow
ANSI.BG_BLUE in ansi -> Color.Blue
ANSI.BG_GRAY in ansi -> Color.Gray
else -> null
}?.let { Modifier.background(it) } ?: Modifier
Box(background.fillMaxWidth()) {
Text(
"[${msg.timeStamp.fmt()}] [${if (msg.stream == Stream.STDOUT) 'O' else 'E'}] ${msg.message}",
fontWeight = weight, textDecoration = decoration, color = color
)
}
}
}
@Composable
fun MonitorDialog(name: String, logs: List<ServerMonitorEvent.ITextEvent>, onClose: () -> Unit, onClear: () -> Unit) {
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()) {
LazyColumn(Modifier.weight(1f).padding(10.dp).background(MaterialTheme.colorScheme.surfaceDim)) {
items(logs) {
MonitorMessage(it)
}
}
}
}
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
@@ -150,7 +233,7 @@ fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM
}
}
}
Box(Modifier.fillMaxSize().padding(15.dp)) {
Box(Modifier.fillMaxSize()) {
ProjectPlotArea(vm, currentBenchmark)
if(editable) {
@@ -160,7 +243,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) {
var lblWidth by mutableStateOf(1000.dp)
var dataWidth by mutableStateOf(1000.dp)
@@ -349,13 +432,7 @@ fun QuickVersion(lbl: ProjectVM.Version, hollow: Boolean = false) {
}
Spacer(Modifier.width(15.dp))
Column {
Row {
Text(lbl.name)
Text(" ${lbl.colorStr}", Modifier.align(Alignment.Bottom),
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(alpha = 0.75f),
)
}
Text(lbl.name, overflow = TextOverflow.Ellipsis, maxLines = 1)
if(lbl.meta.isNotBlank())
Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(0.75f))
@@ -536,7 +613,9 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
val allInputs by vm.inputs
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 timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) }
@@ -552,7 +631,17 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
val enabledHardware = remember(allHardware) { 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)
enabledHardware.clear()
enabledInputs.clear()
@@ -617,15 +706,23 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
var max = 0.0f
baselineComparison = baseline?.let { bl ->
class MeanAccumulator(var sum: Float = 0.0f, var count: Float = 0.0f) {
fun add(f: Float) { sum += f; count += 1.0f }
fun mean() = if(count == 0.0f) 0.0f else sum / count
fun add(f: Float) {
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>>()
filteredEntries.forEach { entry ->
val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum()
perVersion.getOrPut(entry.label) { mutableMapOf() }
.getOrPut(entry.input) { MeanAccumulator() }
.add(corrected)
.add(corrected, entry.measurements.size)
}
val subMeans = perVersion.mapValues { (_, v) -> v.mapValues { (_, acc) -> acc.mean() } }
@@ -656,18 +753,33 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor)
)
Row(Modifier.fillMaxSize().padding(20.dp)) {
Row(Modifier.weight(0.66f).fillMaxHeight()) {
val uiMode = GlobalUiMode.current
var showOptions by remember(uiMode) { mutableStateOf(uiMode == UiMode.WIDE) }
RowOrPanel(useRow = GlobalUiMode.current == UiMode.WIDE, Modifier.fillMaxSize()) {
mainContent { isNarrow, toggleSide ->
Box {
Row(Modifier.padding(end = if (isNarrow) 25.dp else 0.dp)) {
if (entries.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f).padding(35.dp)) {
Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
}
}
else {
Column {
Text("Measurements", style = MaterialTheme.typography.headlineSmall)
} else {
Column(Modifier.padding(35.dp)) {
Row {
Row(Modifier.weight(0.66f), verticalAlignment = Alignment.CenterVertically) {
Text("Measurements", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.width(10.dp))
Text(
if (showOptions) "(hide options)" else "(show options)",
Modifier.clickable { showOptions = !showOptions }.align(Alignment.Bottom),
color = linkColor,
textDecoration = TextDecoration.Underline
)
}
if (showOptions) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("View as: ", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(5.dp))
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
@@ -675,45 +787,56 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
SegmentedButton(
currentUnit == it,
{ currentUnit = it },
shape = SegmentedButtonDefaults.itemShape(idx, TimeUnit.entries.size)
shape = SegmentedButtonDefaults.itemShape(
idx,
TimeUnit.entries.size
)
) {
Text(it.disp)
}
}
}
}
Spacer(Modifier.width(50.dp))
Row(Modifier.weight(0.33f), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(5.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Display: ", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(5.dp))
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
SegmentedButton(!displayWarmups, { displayWarmups = false },
SegmentedButton(
!displayWarmups, { displayWarmups = false },
shape = SegmentedButtonDefaults.itemShape(0, 2)
) {
Text("(Steady-state) measurements")
}
SegmentedButton(displayWarmups, { displayWarmups = true },
SegmentedButton(
displayWarmups, { displayWarmups = true },
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)
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()),
@@ -735,24 +858,49 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
}
}
}
else baselineComparison?.let { blc ->
baselineGraph(baseline, baselineMax, style, gridStyle, blc, currentUnit)
}
}
}
}
Spacer(Modifier.width(20.dp))
if (isNarrow) {
ExpandableSidePanel(
Modifier.width(70.dp).fillMaxHeight().align(Alignment.CenterEnd),
toggleSide = toggleSide, elevation = 10.dp
)
}
}
Column(Modifier.weight(0.33f).fillMaxHeight()) {
Row {
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)
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)
Text(
"No versions yet.",
Modifier.align(Alignment.Center),
fontStyle = FontStyle.Italic
)
}
}
else {
} else {
LazyColumn(Modifier.padding(start = 5.dp)) {
items(versions.toList().sortedBy { it.second.name }) { (_, lbl) ->
Box(Modifier.fillMaxWidth().clickable {
@@ -760,8 +908,7 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
if (versionFilter.size == versions.size) {
versionFilter.clear()
versionFilter.add(lbl)
}
else {
} else {
versionFilter.toggle(lbl)
if (versionFilter.isEmpty()) versionFilter.addAll(versions.values)
}
@@ -781,22 +928,38 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
}
}
if (editable) {
IconButton({ editingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) {
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) {
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))
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")
} else Icon(Lucide.RulerDimensionLine, "Use as baseline")
}
if (editable) {
IconButton({ deletingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) {
IconButton(
{ deletingVersion = lbl },
Modifier.align(Alignment.CenterVertically)
) {
Icon(Lucide.Trash, "Delete version")
}
}
@@ -810,8 +973,12 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
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)
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)) }
@@ -821,18 +988,24 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
if (inputFilter.size == allInputs.size) {
inputFilter.clear()
inputFilter.add(it)
}
else if(inputFilter.size == 1) {
} else if (inputFilter.size == 1) {
inputFilter.addAll(allInputs.toSet()); inputFilter.remove(it)
}
else {
} 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))
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)
)
}
}
@@ -841,13 +1014,16 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
if (inputFilter.size == allInputs.size) {
inputFilter.clear()
inputFilter.add(it)
}
else {
} else {
inputFilter.toggle(it)
if (inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
}
}) {
Text(it, Modifier.padding(start = 5.dp), color = LocalContentColor.current.copy(0.66f))
Text(
it,
Modifier.padding(start = 5.dp),
color = LocalContentColor.current.copy(0.66f)
)
}
}
@@ -857,59 +1033,37 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
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)
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 }) {
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()) }
Text(
it,
Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp),
color = LocalContentColor.current.copy(0.66f)
)
}
}
}
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))
if (!isNarrow) {
baselineComparison?.let { blc ->
baselineGraph(baseline, baselineMax, style, gridStyle, blc, currentUnit)
}
}
}
@@ -932,6 +1086,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
fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit, onDelete: () -> Unit) {
Dialog(onDismissRequest = onCancel) {
@@ -939,7 +1147,7 @@ fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit,
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 $version?")
Text("Are you sure you want to delete ${version.name}?")
Text("This will also remove all data associated with this version.")
Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
@@ -1,10 +1,75 @@
package com.jaytux.phoebench.clients
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.SwingPanel
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import com.jaytux.phoebench.clients.theme.GlobalUiMode
import com.jaytux.phoebench.clients.theme.UiMode
import java.awt.GraphicsConfiguration
import java.awt.GraphicsEnvironment
import java.awt.Toolkit
import java.awt.event.ComponentEvent
import java.awt.event.ComponentListener
import javax.swing.JPanel
fun windowData(config: GraphicsConfiguration): Pair<DpSize, DpOffset> {
val bounds = config.bounds
val insets = Toolkit.getDefaultToolkit().getScreenInsets(config)
val availableWidthPx = bounds.width - insets.left - insets.right
val availableHeightPx = bounds.height - insets.top - insets.bottom
val density = config.defaultTransform.scaleX.toFloat()
val size = DpSize((availableWidthPx / density).dp, (availableHeightPx / density).dp)
val offset = DpOffset((insets.left / density).dp, (insets.top / density).dp)
return size to offset
}
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "PhoeBench") {
val (winSize, winOffset) = windowData(GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration)
val state = rememberWindowState(placement = WindowPlacement.Maximized, size = winSize, position = WindowPosition.Aligned(Alignment.Center))
var mode by remember { mutableStateOf(UiMode.NARROW) }
Window(onCloseRequest = ::exitApplication, state, title = "PhoeBench") {
var width by remember { mutableStateOf(window.width.dp) }
LaunchedEffect(Unit) {
window.addComponentListener(object : ComponentListener {
override fun componentResized(e: ComponentEvent?) {
width = e?.component?.width?.dp ?: width
}
override fun componentMoved(e: ComponentEvent?) {}
override fun componentShown(e: ComponentEvent?) {}
override fun componentHidden(e: ComponentEvent?) {}
})
}
mode = UiMode.fromDp(width)
Column(Modifier.fillMaxSize()) {
SwingPanel(modifier = Modifier.size(0.dp), factory = {
JPanel().apply {
addPropertyChangeListener("graphicsConfiguration") {
val (newSize, newOffset) = windowData(this@Window.window.graphicsConfiguration)
state.size = newSize
state.position = WindowPosition.Aligned(Alignment.Center)
mode = UiMode.fromDp(state.size.width)
}
}
})
CompositionLocalProvider(GlobalUiMode provides mode) {
App()
}
}
}
}
@@ -2,11 +2,7 @@ package com.jaytux.phoebench.clients
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTransferAction
import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.draganddrop.DragAndDropTransferable
import androidx.compose.ui.draganddrop.awtTransferable
import androidx.compose.ui.draganddrop.*
import androidx.compose.ui.platform.ClipEntry
import com.github.javakeyring.Keyring
import com.jaytux.phoebench.clients.ui.DefaultServerSelect
@@ -65,7 +61,7 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
actual suspend fun String.toClipEntry(): ClipEntry =
ClipEntry(StringSelection(this))
actual fun format(f: Float): String = String.format("%.03f", f)
actual fun format(f: Float): String = String.format("%.03f", f).let { it.substring(0, minOf(it.length, 5)) }
@Composable
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate)
@@ -1,14 +1,30 @@
package com.jaytux.phoebench.clients
import androidx.compose.runtime.CompositionLocal
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.ComposeViewport
import com.jaytux.phoebench.clients.theme.GlobalUiMode
import com.jaytux.phoebench.clients.theme.UiMode
import kotlinx.browser.document
import kotlinx.browser.window
@OptIn(ExperimentalComposeUiApi::class)
suspend fun main() {
config = loadConfig()
ComposeViewport(document.body!!) {
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.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import org.w3c.dom.DataTransfer
@@ -63,7 +61,7 @@ actual suspend fun String.toClipEntry(): ClipEntry =
@JsFun("(num) => num.toFixed(3)")
private external fun jsFormat(num: Double): String
actual fun format(f: Float): String = jsFormat(f.toDouble())
actual fun format(f: Float): String = jsFormat(f.toDouble()).let { it.substring(0, minOf(it.length, 5)) }
@Serializable
data class Config(val server: String)
@@ -1,10 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PhoeBench</title>
<link type="text/css" rel="stylesheet" href="styles.css">
<link type="text/css" rel="stylesheet" href="styles.css" />
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-Regular.ttf" />
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-Bold.ttf" />
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-Italic.ttf" />
<link rel="preload" href="composeResources/phoebench.clients.compose.generated.resources/font/JetBrainsMono-BoldItalic.ttf" />
<script type="application/javascript" src="composeApp.js"></script>
</head>
<body>
+1 -1
View File
@@ -18,7 +18,7 @@ val generatePartials = tasks.register<Exec>("generatePartials") {
description = "Generate Partial classes (requests with all-nullable fields)"
val scriptFile = project.file("partialize.main.kts")
val targets = fileTree(requestsDirectory) {
include("**/*.kt")
include("Requests.kt")
}
val lst = targets.map { it.absolutePath }
@@ -0,0 +1,62 @@
package com.jaytux.phoebench.common
import io.ktor.client.plugins.websocket.*
import io.ktor.http.*
import io.ktor.util.reflect.*
import io.ktor.utils.io.*
import io.ktor.websocket.*
import kotlin.uuid.Uuid
sealed class CSERoute<TParams, TEvent: Any>(val path: String, val elevation: Elevation, private val _eventType: TypeInfo) {
open val pattern = path
protected open fun buildUrl(params: TParams): String = path
abstract fun extractParams(reqParams: Parameters): TParams?
suspend fun call(client: IClient, params: TParams, body: suspend (sender: suspend (TEvent) -> Unit) -> Unit): Either<ErrorResponse, Unit> {
val server = client.serverUrl.replace("https", "ws").replace("http", "ws")
var error: ErrorResponse? = null
try {
client.client.webSocket("$server${buildUrl(params)}", {}) {
try {
body { sendSerialized(it, _eventType) }
}
catch(e: CancellationException) {
closeReason.await()?.let {
if(it.code != CloseReason.Codes.NORMAL.code) error = ErrorResponse(it.message)
}
throw e
}
}
return Unit.value()
}
catch(e: CancellationException) {
return (error ?: ErrorResponse(e.message ?: "Unknown websocket error")).error()
}
catch(e: WebSocketException) {
return ErrorResponse("Could not set up websocket stream: ${e.message}").error()
}
catch(e: Exception) {
return ErrorResponse("WebSocket connection failed: ${e.message}").error()
}
}
class CSERoute1<T1, TEvent: Any>(path: String, elevation: Elevation, eventType: TypeInfo, val urlEncode: (T1) -> String, val urlDecode: (String?) -> T1?)
: CSERoute<T1, TEvent>(path, elevation, eventType)
{
override val pattern: String = "$path/{param}"
override fun buildUrl(params: T1): String = "$path/${urlEncode(params)}"
override fun extractParams(reqParams: Parameters): T1? = urlDecode(reqParams["param"])
}
companion object {
inline fun <T1, reified TEvent: Any> single(path: String, elevation: Elevation,
noinline urlEncode: (T1) -> String = { it.toString() }, noinline urlDecode: (String?) -> T1?
) = CSERoute1<T1, TEvent>(path, elevation, typeInfo<TEvent>(), urlEncode, urlDecode)
inline fun <reified TEvent: Any> uuid(path: String, elevation: Elevation) = single<Uuid, TEvent>(path, elevation) {
it?.let { p -> Uuid.parseOrNull(p) }
}
}
}
@@ -0,0 +1,8 @@
package com.jaytux.phoebench.common
enum class CloseReasons(val code: Short) {
NOT_AUTHORIZED(4001),
INVALID_REQUEST(4002),
CONFLICT(4003),
NOT_FOUND(4004)
}
@@ -1,6 +1,8 @@
package com.jaytux.phoebench.common
import kotlinx.serialization.Serializable
import kotlin.collections.fold
import kotlin.time.Instant
import kotlin.uuid.Uuid
@Serializable
@@ -66,3 +68,80 @@ sealed class ProjectEvent {
@Serializable
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 update = ApiRoute.patchUuidNoRes<PartialProjectRequest>("/project", Elevation.AUTH)
val delete = ApiRoute.deleteUuidNoRes("/project", Elevation.AUTH)
val rmMonitor = ApiRoute.deleteUuidNoRes("/project/monitor", Elevation.AUTH)
}
object Benchmark {
@@ -52,5 +53,10 @@ object Routes {
val home = SSERoute.noArgs<HomeEvent>("/rt/home", Elevation.AUTH)
val admin = SSERoute.noArgs<AdminEvent>("/rt/admin", Elevation.ADMIN)
val projectSpecific = SSERoute.uuid<ProjectEvent>("/rt/project", Elevation.AUTH)
val monitor = SSERoute.uuid<ServerMonitorEvent>("/rt/monitor", Elevation.AUTH)
}
object CSE {
val monitor = CSERoute.uuid<ClientMonitorEvent>("/stream/monitor", Elevation.AUTH)
}
}
+4
View File
@@ -23,6 +23,7 @@ kolor-picker = "2.1.0"
shadow = "9.3.0"
clikt = "5.0.3"
buildconfig = "6.0.10"
process = "1.5.1"
[libraries]
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
@@ -50,6 +51,7 @@ ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negoti
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-websocket = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" }
@@ -64,6 +66,7 @@ ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref =
ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" }
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" }
ktor-server-websocket = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
json = { module = "org.json:json", version.ref = "json" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
@@ -87,6 +90,7 @@ kolor = { module = "com.kborowy:kolor-picker", version.ref = "kolor-picker" }
java-keystore = { module = "com.github.javakeyring:java-keyring", version.ref = "java-keystore" }
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
process = { module = "com.github.pgreze:kotlin-process", version.ref = "process" }
[plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
+2 -1
View File
@@ -7,7 +7,7 @@ plugins {
}
group = "com.jaytux.phoebench"
version = PhoebenchVersion(1, 1, 2)
version = rootProject.version as PhoebenchVersion
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("Server version must be at least as high as protocol/common version")
@@ -56,6 +56,7 @@ dependencies {
implementation(libs.ktor.server.cors)
implementation(libs.ktor.server.status.pages)
implementation(libs.ktor.server.sse)
implementation(libs.ktor.server.websocket)
implementation(libs.ktor.serialization.kotlinx.json)
@@ -3,6 +3,8 @@ package com.jaytux.phoebench.server
import com.jaytux.phoebench.common.AdminEvent
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ServerMonitorEvent
import io.ktor.client.plugins.api.MonitoringEvent
import io.ktor.util.reflect.typeInfo
import kotlinx.serialization.serializer
import java.util.concurrent.ConcurrentHashMap
@@ -11,6 +13,10 @@ import kotlin.uuid.Uuid
object Buses {
private val _projectBuses = ConcurrentHashMap<Uuid, SSEBus<ProjectEvent>>()
private val _monitorBuses = ConcurrentHashMap<Uuid, SSEBus.MonitorSSEBus<
ServerMonitorEvent, ServerMonitorEvent.ApplicationStart, ServerMonitorEvent.Message,
ServerMonitorEvent.ApplicationEnd, ServerMonitorEvent.Backlog
>>()
val homeBus = SSEBus<HomeEvent>(typeOf<HomeEvent>(), serializer<HomeEvent>())
val adminBus = SSEBus<AdminEvent>(typeOf<AdminEvent>(), serializer<AdminEvent>())
@@ -19,9 +25,16 @@ object Buses {
SSEBus<ProjectEvent>(typeOf<ProjectEvent>(), serializer<ProjectEvent>())
}
fun monitorBus(id: Uuid) = _monitorBuses.computeIfAbsent(id) {
SSEBus.MonitorSSEBus(
SSEBus(typeOf<ServerMonitorEvent>(), serializer<ServerMonitorEvent>())
) { start, events, end -> ServerMonitorEvent.Backlog(start, events, end) }
}
fun allBuses(): List<SSEBus<*>> {
val res = ArrayList<SSEBus<*>>(_projectBuses.size + 2)
val res = ArrayList<SSEBus<*>>(_projectBuses.size + _monitorBuses.size + 2)
res.addAll(_projectBuses.values)
res.addAll(_monitorBuses.values.map { it.bus })
res.add(homeBus)
res.add(adminBus)
return res
@@ -4,10 +4,15 @@ import com.jaytux.phoebench.common.*
import com.jaytux.phoebench.server.db.DB
import com.jaytux.phoebench.server.db.Meta
import com.jaytux.phoebench.server.db.Metas
import com.jaytux.phoebench.server.db.Project
import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.*
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProjectCSE
import io.ktor.http.*
import io.ktor.serialization.WebsocketContentConverter
import io.ktor.serialization.deserialize
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
@@ -21,8 +26,14 @@ import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sse.*
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.CloseReason
import io.ktor.websocket.close
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.time.Clock
import kotlin.uuid.Uuid
fun Application.module() {
@@ -78,6 +89,10 @@ fun Application.module() {
install(SSE) {}
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
install(StatusPages) {
status(HttpStatusCode.Forbidden) { call, status ->
call.respond(status, ErrorResponse("Access Forbidden: CORS failed."))
@@ -137,6 +152,7 @@ fun Application.module() {
getAuth(Routes.Project.get, ProjectHandler::getProject)
patchAuth(Routes.Project.update, ProjectHandler::updateProject)
deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject)
deleteAuth(Routes.Project.rmMonitor, ProjectHandler::deleteMonitor)
postAuth(Routes.Benchmark.new, ProjectHandler::createBenchmark)
patchAuth(Routes.Benchmark.update, ProjectHandler::updateBenchmark)
@@ -149,13 +165,37 @@ fun Application.module() {
postAuth(Routes.Entry.new, ProjectHandler::createEntry)
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
sseAuth(Routes.SSE.home, { _, _ -> }) { _, _ -> Buses.homeBus }
sseAdmin(Routes.SSE.admin) { _, _ -> Buses.adminBus }
sseAuth(Routes.SSE.home, { _, _ -> }) { _, _, _ -> Buses.homeBus }
sseAdmin(Routes.SSE.admin) { _, _, _ -> Buses.adminBus }
sseAuth(Routes.SSE.projectSpecific,
{ user, uuid ->
transaction { accessibleProject(user, uuid, false) }
}
) { _, id -> Buses.projectBus(id) }
) { _, id, _ -> Buses.projectBus(id) }
sseAuth(Routes.SSE.monitor,
{ user, uuid ->
transaction { accessibleProject(user, uuid, true) }
}
) { _, id, sender ->
val bus = Buses.monitorBus(id)
val backlog = bus.onConnect()
sender(bus.bus.serializer, backlog)
bus.bus
}
cseAuth(Routes.CSE.monitor,
{ id, user -> transaction { accessibleProjectCSE(user, id, true) } },
ProjectHandler::monitorSetup) { _, _, bus, event ->
try {
when(event) {
is ClientMonitorEvent.ApplicationFinished -> bus.end(ServerMonitorEvent.ApplicationEnd(Clock.System.now(), event.exitCode))
is ClientMonitorEvent.Message -> bus.event(ServerMonitorEvent.Message(Clock.System.now(), event.msg, event.options, event.stream))
}
}
catch(e: IllegalStateException) {
throw RouteError.CSERouteError("Could not deliver message: ${e.message ?: "unknown error"}", CloseReasons.CONFLICT)
}
}
}
get("{...}") {
@@ -0,0 +1,35 @@
package com.jaytux.phoebench.server
class MutableBackLog<TStart, TEvent, TEnd> {
var start: TStart? = null
private set
private val _events = mutableListOf<TEvent>()
val events = _events.immutable()
var end: TEnd? = null
private set
fun registerStart(event: TStart) {
if(start != null) throw IllegalStateException("Start is already set.")
start = event
}
fun onEvent(event: TEvent) {
if(start == null) throw IllegalStateException("Start is not set yet.")
if(end != null) throw IllegalStateException("End is already set.")
_events += event
}
fun registerEnd(event: TEnd) {
if(start == null) throw IllegalStateException("Start is not set yet.")
if(end != null) throw IllegalStateException("End is already set.")
end = event
}
fun reset() {
start = null
_events.clear()
end = null
}
fun isRunning() = start != null && end == null
}
@@ -61,4 +61,32 @@ class SSEBus<T>(private val _containedType: KType, val serializer: KSerializer<T
map
}.forEach { it.second.emit(Cancellation.error()) }
}
class MonitorSSEBus<TSuper, TStart : TSuper, TEvent : TSuper, TEnd: TSuper, TBacklog: TSuper>(
val bus: SSEBus<TSuper>, val backlog: MutableBackLog<TStart, TEvent, TEnd> = MutableBackLog(),
val mkBacklog: (start: TStart?, events: List<TEvent>, end: TEnd?) -> TBacklog
) {
val start: TStart? get() = backlog.start
val events: List<TEvent> get() = backlog.events
val end: TEnd? get() = backlog.end
suspend fun start(start: TStart) {
backlog.registerStart(start)
bus.sendAll(start)
}
suspend fun event(event: TEvent) {
backlog.onEvent(event)
bus.sendAll(event)
}
suspend fun end(end: TEnd) {
backlog.registerEnd(end)
bus.sendAll(end)
}
suspend fun onConnect() = mkBacklog(start, events, end)
fun isRunning() = backlog.isRunning()
}
}
@@ -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)
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.wrappedAuth
import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.CSERoute
import com.jaytux.phoebench.common.CloseReasons
import com.jaytux.phoebench.common.Elevation
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.SSERoute
import com.jaytux.phoebench.common.foldSuspend
import com.jaytux.phoebench.server.Auth.setup
import com.jaytux.phoebench.server.SSEBus
import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.RouteError
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.deserialize
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
@@ -31,10 +32,15 @@ import io.ktor.server.routing.delete
import io.ktor.server.routing.patch
import io.ktor.server.sse.heartbeat
import io.ktor.server.sse.sse
import io.ktor.server.websocket.webSocket
import io.ktor.sse.ServerSentEvent
import io.ktor.util.reflect.typeInfo
import io.ktor.utils.io.CancellationException
import io.ktor.websocket.CloseReason
import io.ktor.websocket.close
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.time.Duration.Companion.seconds
@@ -165,7 +171,7 @@ inline fun <reified TReq: Any, reified TRes: Any> Route.patchAdmin(api: ApiRoute
inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrapSSE(
api: SSERoute<TParams, TEvent>, noinline extra: suspend (ApplicationCall, TParams) -> TInter,
noinline prepare: suspend (TInter, TParams) -> SSEBus<TEvent>,
noinline prepare: suspend (TInter, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>,
noinline extract: suspend (SSEBus<TEvent>, TInter, TParams) -> SharedFlow<TFlow>,
noinline handler: suspend (TFlow, sender: suspend (TEvent) -> Unit) -> Unit,
noinline onCancel: suspend (SSEBus<TEvent>, TInter, TParams, CancellationException) -> Unit
@@ -182,11 +188,14 @@ inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrap
)
val inter = extra(call, params)
val bus = prepare(inter, params)
val bus = prepare(inter, params) { serializer, event -> send(ServerSentEvent(data = Json.encodeToString(serializer, event))) }
val stream = extract(bus, inter, params)
try {
stream.collect {
handler(it) { ev -> send(ServerSentEvent(data = Json.encodeToString(bus.serializer, ev))) }
handler(it) { ev ->
println("[SSE ${api.pattern}]: sending event!")
send(ServerSentEvent(data = Json.encodeToString(bus.serializer, ev)))
}
}
}
catch(e: CancellationException) {
@@ -214,7 +223,7 @@ inline fun <reified TParams: Any, reified TEvent: Any, TInter, TFlow> Route.wrap
inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
api: SSERoute<TParams, TEvent>,
noinline verifyUser: suspend (User, TParams) -> Unit,
noinline prepare: suspend (User, TParams) -> SSEBus<TEvent>
noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
) = wrapSSE(api,
extra = { call, params ->
val principal = call.principal<JWTPrincipal>()
@@ -239,23 +248,113 @@ inline fun <reified TParams: Any, reified TEvent: Any> Route.wrapAuthSSE(
onCancel = { bus, user, _, _ -> bus.disconnect(user.id.value) }
)
inline fun <reified TParams: Any, reified TEvent: Any> Route.sse(api: SSERoute<TParams, TEvent>, noinline setup: suspend (TParams) -> SSEBus<TEvent>) =
wrapSSE(api,
inline fun <reified TParams: Any, reified TEvent: Any> Route.sse(api: SSERoute<TParams, TEvent>, noinline prepare: suspend (TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>) {
if(api.elevation != Elevation.UN_AUTH) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
wrapSSE(
api,
extra = { _, _ -> },
prepare = { _, params -> setup(params) },
prepare = { _, params, sender -> prepare(params, sender) },
extract = { bus, _, _ -> bus.unRegistered() },
handler = { it, sender -> sender(it) },
onCancel = { _, _, _, _ -> }
)
}
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAuth(api: SSERoute<TParams, TEvent>,
noinline verifyUser: suspend (User, TParams) -> Unit, noinline setup: suspend (User, TParams) -> SSEBus<TEvent>
) = wrapAuthSSE(api, verifyUser, setup)
noinline verifyUser: suspend (User, TParams) -> Unit, noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
) {
if(api.elevation != Elevation.AUTH) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthSSE(api, verifyUser, prepare)
}
inline fun <reified TParams: Any, reified TEvent: Any> Route.sseAdmin(api: SSERoute<TParams, TEvent>,
noinline setup: suspend (User, TParams) -> SSEBus<TEvent>
) = wrapAuthSSE(api, { user, _ ->
noinline prepare: suspend (User, TParams, sender: suspend (KSerializer<TEvent>, TEvent) -> Unit) -> SSEBus<TEvent>
) {
if(api.elevation != Elevation.ADMIN) throw IllegalArgumentException("SSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthSSE(api, { user, _ ->
if(!user.isAdmin) {
throw RouteError("Admin access required", HttpStatusCode.Forbidden)
}
}, setup)
}, prepare)
}
inline fun <reified TParams: Any, reified TEvent: Any, TInter, TExtra> Route.wrapCSE(
api: CSERoute<TParams, TEvent>,
noinline extra: suspend (ApplicationCall, TParams) -> TInter,
noinline setup: suspend (TParams, TInter) -> TExtra,
noinline handler: suspend (TParams, TInter, TExtra, TEvent) -> Unit
) {
webSocket(api.pattern) {
try {
val params = api.extractParams(call.parameters) ?:
throw RouteError.CSERouteError("Missing or malformed parameters for CSE ${api.pattern}", CloseReasons.INVALID_REQUEST)
val inter = extra(call, params)
val converter = KotlinxWebsocketSerializationConverter(Json)
val extra = setup(params, inter)
for(frame in incoming) {
handler(params, inter, extra, converter.deserialize<TEvent>(frame))
}
}
catch(e: ClosedReceiveChannelException) {}
catch(e: RouteError.CSERouteError) {
close(CloseReason(e.code, e.message ?: "Unknown error"))
}
catch(e: Exception) {
close(CloseReason(CloseReason.Codes.INTERNAL_ERROR, e.message ?: "Unknown error"))
}
}
}
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.wrapAuthCSE(
api: CSERoute<TParams, TEvent>,
noinline verifyUser: suspend (TParams, User) -> Unit,
noinline setup: suspend (TParams, User) -> TExtra,
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
) = wrapCSE<TParams, TEvent, User, TExtra>(
api = api,
extra = { call, params ->
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.getClaim(com.jaytux.phoebench.common.Auth.JWT_CLAIM)?.asString()
?: throw RouteError.CSERouteError("Missing user claim", CloseReasons.NOT_AUTHORIZED)
val user = transaction {
User.findById(Uuid.parse(userId)) ?: throw RouteError.CSERouteError(
"User not found",
CloseReasons.NOT_AUTHORIZED
)
}
verifyUser(params, user)
user
},
setup = setup,
handler = handler
)
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cse(
api: CSERoute<TParams, TEvent>,
noinline setup: suspend (TParams, Unit) -> TExtra,
noinline handler: suspend (TParams, TExtra, TEvent) -> Unit
) {
if(api.elevation != Elevation.UN_AUTH) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
wrapCSE(api, { _, _ -> }, setup) { params, _, extra, event -> handler(params, extra, event) }
}
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cseAuth(
api: CSERoute<TParams, TEvent>, noinline verifyUser: suspend (TParams, User) -> Unit,
noinline setup: suspend (TParams, User) -> TExtra, noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
) {
if(api.elevation != Elevation.AUTH) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthCSE(api, verifyUser, setup, handler)
}
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cseAdmin(
api: CSERoute<TParams, TEvent>, noinline setup: suspend (TParams, User) -> TExtra,
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
) {
if(api.elevation != Elevation.ADMIN) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthCSE(api, { _, user ->
if(!user.isAdmin) {
throw RouteError.CSERouteError("Admin access required", CloseReasons.NOT_AUTHORIZED)
}
}, setup, handler)
}
@@ -3,6 +3,8 @@ package com.jaytux.phoebench.server.handlers
import com.jaytux.phoebench.common.BenchmarkRequest
import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.BenchmarkSummary
import com.jaytux.phoebench.common.ClientMonitorEvent
import com.jaytux.phoebench.common.CloseReasons
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.EmptyResponse
import com.jaytux.phoebench.common.EntryRequest
@@ -19,7 +21,9 @@ import com.jaytux.phoebench.common.PartialVersionRequest
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.ProjectResponse
import com.jaytux.phoebench.common.ServerMonitorEvent
import com.jaytux.phoebench.server.Buses
import com.jaytux.phoebench.server.SSEBus
import com.jaytux.phoebench.server.app
import com.jaytux.phoebench.server.db.Benchmark
import com.jaytux.phoebench.server.db.Benchmarks
@@ -43,6 +47,7 @@ import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.notInList
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.time.Clock
import kotlin.uuid.Uuid
object ProjectHandler {
@@ -68,6 +73,11 @@ object ProjectHandler {
return project.isAccessible(user, forEditing)
}
fun Transaction.accessibleProjectCSE(user: User, id: Uuid, forEditing: Boolean): Project {
val project = Project.findById(id) ?: throw RouteError.CSERouteError("Invalid project ID.", CloseReasons.NOT_FOUND)
return project.isAccessible(user, forEditing)
}
fun Transaction.accessibleBenchmark(user: User, id: Uuid, forEditing: Boolean): Pair<Project, Benchmark> {
val bench = Benchmark.findById(id) ?: throw RouteError("Invalid benchmark ID.", HttpStatusCode.NotFound)
return bench.isAccessible(user, forEditing)
@@ -381,4 +391,26 @@ object ProjectHandler {
return home(user, EmptyRequest())
}
suspend fun monitorSetup(id: Uuid, user: User) = Buses.monitorBus(id).also {
try {
if(!it.isRunning()) it.backlog.reset()
it.start(ServerMonitorEvent.ApplicationStart(Clock.System.now()))
}
catch(e: IllegalStateException) {
throw RouteError.CSERouteError("Monitor may already be running. Please refrain from starting a second trace.", CloseReasons.CONFLICT)
}
}
fun deleteMonitor(user: User, req: Uuid) = transaction {
checkMigration(user)
accessibleProject(user, req, true)
val bus = Buses.monitorBus(req)
if(bus.isRunning()) throw RouteError("Monitor is still running", HttpStatusCode.Conflict)
bus.backlog.reset()
ServerScope.launch {
bus.bus.sendAll(ServerMonitorEvent.Cleared)
}
success(EmptyResponse())
}
}
@@ -1,6 +1,7 @@
package com.jaytux.phoebench.server.handlers
import com.jaytux.phoebench.common.Auth
import com.jaytux.phoebench.common.CloseReasons
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.server.db.User
import io.ktor.http.ContentType
@@ -13,6 +14,7 @@ import io.ktor.server.response.respondText
import io.ktor.server.routing.RoutingCall
import io.ktor.server.routing.RoutingContext
import io.ktor.util.logging.KtorSimpleLogger
import io.ktor.websocket.CloseReason
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.uuid.Uuid
@@ -75,4 +77,9 @@ open class RouteError(message: String, val status: HttpStatusCode = HttpStatusCo
fun unauthorized(message: String): Nothing =
throw RouteError(message, HttpStatusCode.Unauthorized)
}
class CSERouteError(message: String, val code: Short) : RouteError(message, HttpStatusCode.NotImplemented) {
constructor(message: String, code: CloseReason.Codes) : this(message, code.code)
constructor(message: String, code: CloseReasons) : this(message, code.code)
}
}