Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
126a3bfff2
|
||
|
|
563ee98d03
|
||
|
|
7f92df1ca5
|
||
|
|
768bb0c650
|
||
|
|
85902620da
|
||
|
|
8da391b6ce
|
||
|
|
bc9861900e
|
||
|
|
1d10802140
|
+2
-2
@@ -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
@@ -14,4 +14,4 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
version = PhoebenchVersion(1, 1, 0, "")
|
||||
version = PhoebenchVersion(1, 2, 1, "")
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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})")
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
}
|
||||
+5
@@ -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)
|
||||
|
||||
+44
-2
@@ -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
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package com.jaytux.phoebench.clients.ui
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.PanelRightClose
|
||||
import com.composables.icons.lucide.PanelRightOpen
|
||||
import com.jaytux.phoebench.clients.app
|
||||
|
||||
@DslMarker annotation class NarrowDslMarker
|
||||
@DslMarker annotation class TabPanelDslMarker
|
||||
|
||||
@NarrowDslMarker
|
||||
interface INarrowDsl {
|
||||
fun mainContent(content: @Composable ((isNarrow: Boolean, toggleSide: () -> Unit) -> Unit))
|
||||
fun sideContent(onSurface: Boolean, content: @Composable (isNarrow: Boolean, toggleSide: () -> Unit) -> Unit)
|
||||
}
|
||||
|
||||
internal class NarrowDslImpl : INarrowDsl {
|
||||
var main: @Composable (isNarrow: Boolean, toggleSide: () -> Unit) -> Unit = { _, _ -> }
|
||||
private set
|
||||
var side: @Composable (isNarrow: Boolean, toggleSide: () -> Unit) -> Unit = { _, _ -> }
|
||||
private set
|
||||
var sideSurface: Boolean = true
|
||||
private set
|
||||
|
||||
override fun mainContent(content: @Composable ((isNarrow: Boolean, toggleSide: () -> Unit) -> Unit)) { main = content }
|
||||
override fun sideContent(onSurface: Boolean, content: @Composable ((isNarrow: Boolean, toggleSide: () -> Unit) -> Unit)) { sideSurface = onSurface; side = content }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RowOrPanel(useRow: Boolean, modifier: Modifier = Modifier, @FloatRange(0.0, 1.0) defaultWeight: Float = 0.5f, builder: INarrowDsl.() -> Unit) {
|
||||
val content = NarrowDslImpl().also { it.builder() }
|
||||
|
||||
if(!useRow) {
|
||||
var isOpen by remember { mutableStateOf(false) }
|
||||
Box(modifier) {
|
||||
content.main(true) { isOpen = !isOpen }
|
||||
if(isOpen) {
|
||||
CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) {
|
||||
Box(Modifier.matchParentSize().background(Color.Black.copy(alpha = 0.5f)).clickable { isOpen = false })
|
||||
}
|
||||
val shape = MaterialTheme.shapes.medium.copy(topEnd = CornerSize(0.dp), bottomEnd = CornerSize(0.dp))
|
||||
Box(Modifier.matchParentSize()) {
|
||||
if(content.sideSurface) {
|
||||
Surface(Modifier.align(Alignment.CenterEnd).fillMaxHeight().fillMaxWidth(0.9f), shape = shape, shadowElevation = 3.dp) {
|
||||
Box(Modifier.padding(10.dp)) {
|
||||
content.side(true) { isOpen = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Box(Modifier.align(Alignment.CenterEnd)) {
|
||||
content.side(true) { isOpen = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Row(modifier) {
|
||||
Box(Modifier.weight(defaultWeight)) { content.main(false) {} }
|
||||
Box(Modifier.weight(1.0f - defaultWeight)) { content.side(false) {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TabPanelShape(val tabWidth: Dp, val tabHeight: Dp = tabWidth, val cornerRadius: CornerSize) : Shape {
|
||||
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
|
||||
val (tabWpx, tabHpx, rpx) = with(density) {
|
||||
tabWidth.toPx() to tabHeight.toPx() app cornerRadius.toPx(size, density)
|
||||
}
|
||||
|
||||
val radius = CornerRadius(rpx, rpx)
|
||||
|
||||
val shape = Path().apply {
|
||||
addRoundRect(RoundRect(
|
||||
Rect(offset = Offset(x = tabWpx, y = 0f), size = Size(width = size.width - tabWpx, height = size.height)),
|
||||
topLeft = CornerRadius.Zero, topRight = CornerRadius.Zero,
|
||||
bottomLeft = radius, bottomRight = CornerRadius.Zero
|
||||
))
|
||||
|
||||
addRoundRect(RoundRect(
|
||||
Rect(offset = Offset(x = 0f, y = 0f), size = Size(size.width, tabHpx)),
|
||||
topLeft = radius, topRight = CornerRadius.Zero,
|
||||
bottomLeft = radius, bottomRight = CornerRadius.Zero
|
||||
))
|
||||
}
|
||||
return Outline.Generic(shape)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabPanel(tabWidth: Dp, tabHeight: Dp = tabWidth, modifier: Modifier = Modifier,
|
||||
cornerRadius: CornerSize = MaterialTheme.shapes.medium.topEnd,
|
||||
color: Color = MaterialTheme.colorScheme.surface, contentColor: Color = contentColorFor(color),
|
||||
tonalElevation: Dp = 0.dp, shadowElevation: Dp = 0.dp, border: BorderStroke? = null,
|
||||
tabContent: @Composable () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Surface(modifier,
|
||||
shape = TabPanelShape(tabWidth, tabHeight, cornerRadius), color = color, contentColor = contentColor,
|
||||
tonalElevation = tonalElevation, shadowElevation = shadowElevation, border = border
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.size(width = tabWidth, height = tabHeight), propagateMinConstraints = true) { tabContent() }
|
||||
Box(Modifier.padding(start = tabWidth)) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabPanel(tabWidth: Dp, modifier: Modifier, tabHeight: Dp = tabWidth,
|
||||
cornerRadius: CornerSize = MaterialTheme.shapes.medium.topEnd,
|
||||
color: Color = MaterialTheme.colorScheme.surface, contentColor: Color = contentColorFor(color),
|
||||
tonalElevation: Dp = 0.dp, shadowElevation: Dp = 0.dp, border: BorderStroke? = null,
|
||||
tabContent: @Composable () -> Unit, content: @Composable () -> Unit) =
|
||||
TabPanel(tabWidth, tabHeight, modifier, cornerRadius, color, contentColor, tonalElevation, shadowElevation, border, tabContent, content)
|
||||
|
||||
@Composable
|
||||
fun ExpandableSidePanel(
|
||||
modifier: Modifier = Modifier, tabWidth: Dp = 40.dp, tabHeight: Dp = tabWidth,
|
||||
radius: CornerSize = MaterialTheme.shapes.medium.topEnd, elevation: Dp = 5.dp,
|
||||
color: Color = MaterialTheme.colorScheme.surface, toggleSide: () -> Unit
|
||||
) = TabPanel(tabWidth, tabHeight, modifier, radius, color, shadowElevation = elevation, tonalElevation = elevation,
|
||||
tabContent = {
|
||||
Surface(Modifier.padding(5.dp), shape = MaterialTheme.shapes.medium) {
|
||||
Icon(Lucide.PanelRightOpen, "Open side panel",
|
||||
Modifier.aspectRatio(1f).fillMaxSize(0.66f).clickable { toggleSide() })
|
||||
}
|
||||
}
|
||||
) {}
|
||||
|
||||
@Composable
|
||||
fun ExpandedSidePanel(
|
||||
modifier: Modifier = Modifier, tabWidth: Dp = 40.dp, tabHeight: Dp = tabWidth,
|
||||
radius: CornerSize = MaterialTheme.shapes.medium.topEnd, elevation: Dp = 5.dp,
|
||||
color: Color = MaterialTheme.colorScheme.surface, toggleSide: () -> Unit, content: @Composable () -> Unit
|
||||
) = TabPanel(tabWidth, tabHeight, modifier, radius, color, shadowElevation = elevation, tonalElevation = elevation,
|
||||
tabContent = {
|
||||
Surface(Modifier.padding(5.dp), shape = MaterialTheme.shapes.medium) {
|
||||
Icon(Lucide.PanelRightClose, "Close side panel",
|
||||
Modifier.aspectRatio(1f).fillMaxSize(0.66f).clickable { toggleSide() })
|
||||
}
|
||||
}, content = content
|
||||
)
|
||||
+328
-120
@@ -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) }
|
||||
@@ -61,7 +68,7 @@ fun ProjectView(id: Uuid, forceBack: () -> Unit) {
|
||||
Column(Modifier.padding(all = 15.dp)) {
|
||||
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Project ${name ?: "Unnamed Project"}", style = MaterialTheme.typography.headlineMedium)
|
||||
if(editable) {
|
||||
if (editable) {
|
||||
Spacer(Modifier.width(25.dp))
|
||||
IconButton({ editing = true }) {
|
||||
Icon(Lucide.Pencil, "Edit project details")
|
||||
@@ -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,16 +631,26 @@ 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()
|
||||
|
||||
filteredEntries = entries.filter { entry ->
|
||||
if(entry.label !in versionFilter) return@filter false
|
||||
if(!(entry.timeStamp inRange timeRange)) return@filter false
|
||||
if(entry.input !in inputFilter) return@filter false
|
||||
if(filterHardware != null && entry.hardware != filterHardware) return@filter false
|
||||
if (entry.label !in versionFilter) return@filter false
|
||||
if (!(entry.timeStamp inRange timeRange)) return@filter false
|
||||
if (entry.input !in inputFilter) return@filter false
|
||||
if (filterHardware != null && entry.hardware != filterHardware) return@filter false
|
||||
|
||||
enabledHardware.add(entry.hardware)
|
||||
enabledInputs.add(entry.input)
|
||||
@@ -582,7 +671,7 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
|
||||
var maxY = 0f
|
||||
|
||||
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())
|
||||
use.mapIndexed { idx, it ->
|
||||
val converted = entry.nativeUnit.convertTo(currentUnit, it)
|
||||
@@ -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()) {
|
||||
if(entries.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
|
||||
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).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,35 +858,59 @@ 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()) {
|
||||
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 {
|
||||
baseline = null
|
||||
if(versionFilter.size == versions.size) {
|
||||
if (versionFilter.size == versions.size) {
|
||||
versionFilter.clear()
|
||||
versionFilter.add(lbl)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
versionFilter.toggle(lbl)
|
||||
if(versionFilter.isEmpty()) versionFilter.addAll(versions.values)
|
||||
if (versionFilter.isEmpty()) versionFilter.addAll(versions.values)
|
||||
}
|
||||
}) {
|
||||
Row {
|
||||
@@ -780,23 +927,39 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if(editable) {
|
||||
IconButton({ editingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) {
|
||||
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))
|
||||
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")
|
||||
}
|
||||
else Icon(Lucide.RulerDimensionLine, "Use as baseline")
|
||||
}
|
||||
if(editable) {
|
||||
IconButton({ deletingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) {
|
||||
if (editable) {
|
||||
IconButton(
|
||||
{ deletingVersion = lbl },
|
||||
Modifier.align(Alignment.CenterVertically)
|
||||
) {
|
||||
Icon(Lucide.Trash, "Delete version")
|
||||
}
|
||||
}
|
||||
@@ -810,44 +973,57 @@ 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)) }
|
||||
|
||||
items((visibleInputs intersect inputFilter.toSet()).sorted()) {
|
||||
Row(Modifier.fillMaxWidth().clickable {
|
||||
if(inputFilter.size == allInputs.size) {
|
||||
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())
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items((visibleInputs - inputFilter.toSet()).sorted()) {
|
||||
Row(Modifier.fillMaxWidth().clickable {
|
||||
if(inputFilter.size == allInputs.size) {
|
||||
if (inputFilter.size == allInputs.size) {
|
||||
inputFilter.clear()
|
||||
inputFilter.add(it)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
inputFilter.toggle(it)
|
||||
if(inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
|
||||
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
-6
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -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>
|
||||
|
||||
@@ -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 = if(client.serverUrl.startsWith("http")) client.serverUrl.replace("http", "ws") else "ws://${client.serverUrl}"
|
||||
var error: ErrorResponse? = null
|
||||
try {
|
||||
client.client.webSocket("$server${buildUrl(params)}", {}) {
|
||||
try {
|
||||
body { sendSerialized(it, _eventType) }
|
||||
}
|
||||
catch(e: CancellationException) {
|
||||
closeReason.await()?.let {
|
||||
if(it.code != CloseReason.Codes.NORMAL.code) error = ErrorResponse(it.message)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return Unit.value()
|
||||
}
|
||||
catch(e: CancellationException) {
|
||||
return (error ?: ErrorResponse(e.message ?: "Unknown websocket error")).error()
|
||||
}
|
||||
catch(e: WebSocketException) {
|
||||
return ErrorResponse("Could not set up websocket stream: ${e.message}").error()
|
||||
}
|
||||
catch(e: Exception) {
|
||||
return ErrorResponse("WebSocket connection failed: ${e.message}").error()
|
||||
}
|
||||
}
|
||||
|
||||
class CSERoute1<T1, TEvent: Any>(path: String, elevation: Elevation, eventType: TypeInfo, val urlEncode: (T1) -> String, val urlDecode: (String?) -> T1?)
|
||||
: CSERoute<T1, TEvent>(path, elevation, eventType)
|
||||
{
|
||||
override val pattern: String = "$path/{param}"
|
||||
override fun buildUrl(params: T1): String = "$path/${urlEncode(params)}"
|
||||
override fun extractParams(reqParams: Parameters): T1? = urlDecode(reqParams["param"])
|
||||
}
|
||||
|
||||
companion object {
|
||||
inline fun <T1, reified TEvent: Any> single(path: String, elevation: Elevation,
|
||||
noinline urlEncode: (T1) -> String = { it.toString() }, noinline urlDecode: (String?) -> T1?
|
||||
) = CSERoute1<T1, TEvent>(path, elevation, typeInfo<TEvent>(), urlEncode, urlDecode)
|
||||
|
||||
inline fun <reified TEvent: Any> uuid(path: String, elevation: Elevation) = single<Uuid, TEvent>(path, elevation) {
|
||||
it?.let { p -> Uuid.parseOrNull(p) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.jaytux.phoebench.common
|
||||
|
||||
enum class CloseReasons(val code: Short) {
|
||||
NOT_AUTHORIZED(4001),
|
||||
INVALID_REQUEST(4002),
|
||||
CONFLICT(4003),
|
||||
NOT_FOUND(4004)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.jaytux.phoebench.common
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.collections.fold
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Serializable
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user