Author SHA1 Message Date
jaytux 589649d9f6 Server: patch crashing applications blocks monitor 2026-09-02 15:11:06 +02:00
jaytux ae7724b3c4 UI: re-render when versions change 2026-09-01 20:12:29 +02:00
jaytux ef5ccb9274 UI: auto-scrolling 2026-09-01 16:57:21 +02:00
jaytux 9e010c7873 Server: harden against crashes 2026-09-01 16:31:00 +02:00
jaytux fce1c097d4 Java Preferences sync bug 2026-09-01 16:01:18 +02:00
jaytux b5c4e150a5 UI: removing benchmarks 2026-09-01 15:28:52 +02:00
jaytux 126a3bfff2 Clients: use http->ws, https->wss 2026-09-01 15:17:39 +02:00
jaytux 563ee98d03 ANSI update 2026-09-01 14:58:17 +02:00
jaytux 7f92df1ca5 CLI: fix keyring backend bug 2026-09-01 14:09:37 +02:00
15 changed files with 118 additions and 43 deletions
+1 -1
View File
@@ -14,4 +14,4 @@ repositories {
mavenCentral()
}
version = PhoebenchVersion(1, 2, 0, "")
version = PhoebenchVersion(1, 2, 1, "")
+4 -2
View File
@@ -9,7 +9,7 @@ plugins {
}
group = "com.jaytux.phoebench"
version = rootProject.version as PhoebenchVersion
version = PhoebenchVersion(1, 2, 1, "patch0")
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("CLI Client version must be at least as high as protocol/common version")
@@ -80,7 +80,9 @@ kotlin {
}
tasks.withType<ShadowJar> {
archiveFileName = if(isDebug) "phoebench-cli-debug.jar" else "phoebench-cli.jar"
val archive = if(isDebug) "phoebench-cli-debug.jar" else "phoebench-cli.jar"
println("Using archive '$archive'")
archiveFileName = archive
}
buildConfig {
@@ -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() }
}
}
@@ -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
@@ -22,7 +23,11 @@ object PersistentStorage {
private object KeyringBackend : IBackend {
override fun read(key: String): String? = try {
Keyring.create().use { it.getPassword(SERVICE, key) }
} catch(e: Exception) {
}
catch(e: PasswordAccessException) {
return null
}
catch(e: BackendNotSupportedException) {
_backend = PreferencesBackend
_backend.read(key)
}
@@ -52,6 +57,7 @@ object PersistentStorage {
override fun write(key: String, value: String) {
prefs.put(key, value)
prefs.flush()
}
override fun erase(key: String) {
+1 -1
View File
@@ -13,7 +13,7 @@ plugins {
alias(libs.plugins.buildconfig)
}
version = rootProject.version as PhoebenchVersion
version = PhoebenchVersion(1, 2, 1, "autoscroll")
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("UI Clients version must be at least as high as protocol/common version")
@@ -138,7 +138,11 @@ class ProjectVM(
when(event) {
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.VersionChanged -> {
val updVersion = Version.fromResponse(event.label)
_labels.value += (event.label.id to updVersion)
_benchmarks.value = _benchmarks.value.map { b -> b.copy(entries = b.entries.map { e -> if(e.label.id == event.label.id) e.copy(label = updVersion) else e }) }
}
is ProjectEvent.VersionDeleted -> {
_labels.value = _labels.value.filter { it.value.id != event.id }
_benchmarks.value = _benchmarks.value.map { it.copy(entries = it.entries.filter { e -> e.label.id != event.id }) }
@@ -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
@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -133,24 +134,20 @@ fun MonitorMessage(msg: ServerMonitorEvent.ITextEvent) = when(msg) {
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_BLACK in ansi -> Color.Black
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_MAGENTA in ansi -> Color.Magenta
ANSI.FG_CYAN in ansi -> Color.Cyan
ANSI.FG_GRAY in ansi -> Color.Gray
else -> LocalContentColor.current
}
val background = when {
ANSI.BG_BLACK in ansi -> Color.Black
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_MAGENTA in ansi -> Color.Magenta
ANSI.BG_CYAN in ansi -> Color.Cyan
ANSI.BG_GRAY in ansi -> Color.Gray
else -> null
}?.let { Modifier.background(it) } ?: Modifier
@@ -165,6 +162,15 @@ fun MonitorMessage(msg: ServerMonitorEvent.ITextEvent) = when(msg) {
@Composable
fun MonitorDialog(name: String, logs: List<ServerMonitorEvent.ITextEvent>, onClose: () -> Unit, onClear: () -> Unit) {
var autoFollow by remember { mutableStateOf(true) }
val state = rememberLazyListState()
LaunchedEffect(autoFollow, logs) {
if(autoFollow) {
state.animateScrollToItem(logs.size - 1)
}
}
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Surface(Modifier.padding(15.dp).fillMaxHeight(0.8f).widthIn(min = 300.dp, max = 2000.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp)) {
@@ -179,11 +185,22 @@ fun MonitorDialog(name: String, logs: List<ServerMonitorEvent.ITextEvent>, onClo
}
else {
CompositionLocalProvider(LocalTextStyle provides LocalTextStyle.current.makeMonospaced()) {
LazyColumn(Modifier.weight(1f).padding(10.dp).background(MaterialTheme.colorScheme.surfaceDim)) {
Box(Modifier.weight(1f)) {
LazyColumn(Modifier.fillMaxHeight().fillMaxWidth().padding(10.dp).background(MaterialTheme.colorScheme.surfaceDim), state) {
items(logs) {
MonitorMessage(it)
}
}
Box(Modifier.align(Alignment.TopEnd).padding(25.dp)) {
val colors = if(autoFollow)
IconButtonDefaults.iconButtonColors(containerColor = LocalContentColor.current, contentColor = MaterialTheme.colorScheme.surface)
else IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.surface, contentColor = LocalContentColor.current)
IconButton({ autoFollow = !autoFollow }, colors = colors) {
Icon(Lucide.ArrowDownToLine, "Follow")
}
}
}
}
}
Spacer(Modifier.height(10.dp))
@@ -203,6 +220,7 @@ fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM
var addingLabel by remember { mutableStateOf(false) }
var addingData by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(false) }
Surface(Modifier.fillMaxSize().padding(8.dp), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) {
Column(Modifier.fillMaxSize()) {
@@ -221,6 +239,9 @@ fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM
IconButton({ editing = true }) {
Icon(Lucide.Pencil, "Edit benchmark")
}
IconButton({ deleting = true }) {
Icon(Lucide.Trash, "Remove benchmark")
}
Icon(Lucide.ChevronDown, "Select benchmark...")
}
}
@@ -319,6 +340,8 @@ fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM
}
if(editing) EditBenchmarkDialog(currentBenchmark, { editing = false }) { vm.updateBenchmark(currentBenchmark.id, it) }
if(deleting) ConfirmDeleteBenchmarkDialog(currentBenchmark, { deleting = false }) { vm.deleteBenchmark(currentBenchmark.id); deleting = false }
}
@Composable
@@ -363,6 +386,21 @@ fun ConfirmDeleteProjectDialog(name: String, onCancel: () -> Unit, onDelete: ()
}
}
@Composable
fun ConfirmDeleteBenchmarkDialog(benchmark: ProjectVM.Benchmark, onCancel: () -> Unit, onDelete: () -> Unit) {
Dialog(onDismissRequest = onCancel) {
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) {
Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(10.dp))
Text("Are you sure you want to delete ${benchmark.name}?")
Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
}
}
}
}
@Composable
fun AddBenchmarkDialog(onCancel: () -> Unit, onAdd: (name: String) -> Unit) {
Dialog(onDismissRequest = onCancel) {
@@ -15,7 +15,7 @@ sealed class CSERoute<TParams, TEvent: Any>(val path: String, val elevation: Ele
abstract fun extractParams(reqParams: Parameters): TParams?
suspend fun call(client: IClient, params: TParams, body: suspend (sender: suspend (TEvent) -> Unit) -> Unit): Either<ErrorResponse, Unit> {
val server = client.serverUrl.replace("https", "ws").replace("http", "ws")
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)}", {}) {
@@ -78,13 +78,11 @@ sealed class ProjectEvent {
enum class ANSI(val bitIdx: Int, val ansiCode: Int) {
BOLD(0, 1), UNDERLINE(1, 4),
FG_BLACK(2, 30), FG_RED(3, 31), FG_GREEN(4, 32),
FG_YELLOW(5, 33), FG_BLUE(6, 34), FG_MAGENTA(7, 35),
FG_CYAN(8, 36),
FG_RED(2, 31), FG_GREEN(3, 32), FG_YELLOW(4, 33),
FG_BLUE(5, 34), FG_GRAY(6, 90),
BG_BLACK(9, 40), BG_RED(10, 41), BG_GREEN(11, 42),
BG_YELLOW(12, 43), BG_BLUE(13, 44), BG_MAGENTA(14, 45),
BG_CYAN(15, 46);
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>
+1 -1
View File
@@ -7,7 +7,7 @@ plugins {
}
group = "com.jaytux.phoebench"
version = rootProject.version as PhoebenchVersion
version = PhoebenchVersion(1, 2, 1, "patch1")
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("Server version must be at least as high as protocol/common version")
@@ -179,14 +179,13 @@ fun Application.module() {
) { _, id, sender ->
val bus = Buses.monitorBus(id)
val backlog = bus.onConnect()
println("Bus [$id] connected; sending backlog (${backlog.start != null} // ${backlog.messages.size} // ${backlog.end != null})")
sender(bus.bus.serializer, backlog)
bus.bus
}
cseAuth(Routes.CSE.monitor,
{ id, user -> transaction { accessibleProjectCSE(user, id, true) } },
ProjectHandler::monitorSetup) { _, _, bus, event ->
ProjectHandler::monitorSetup, ProjectHandler::monitorTeardown) { _, _, bus, event ->
try {
when(event) {
is ClientMonitorEvent.ApplicationFinished -> bus.end(ServerMonitorEvent.ApplicationEnd(Clock.System.now(), event.exitCode))
@@ -11,21 +11,18 @@ class MutableBackLog<TStart, TEvent, TEnd> {
fun registerStart(event: TStart) {
if(start != null) throw IllegalStateException("Start is already set.")
start = event
println("[BACKLOG]: 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
println("[BACKLOG]: event '$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
println("[BACKLOG]: end '$event'")
}
fun reset() {
@@ -282,6 +282,7 @@ inline fun <reified TParams: Any, reified TEvent: Any, TInter, TExtra> Route.wra
api: CSERoute<TParams, TEvent>,
noinline extra: suspend (ApplicationCall, TParams) -> TInter,
noinline setup: suspend (TParams, TInter) -> TExtra,
noinline onClose: suspend (TParams, TInter, TExtra) -> Unit,
noinline handler: suspend (TParams, TInter, TExtra, TEvent) -> Unit
) {
webSocket(api.pattern) {
@@ -292,11 +293,15 @@ inline fun <reified TParams: Any, reified TEvent: Any, TInter, TExtra> Route.wra
val converter = KotlinxWebsocketSerializationConverter(Json)
val extra = setup(params, inter)
for(frame in incoming) {
try {
for (frame in incoming) {
handler(params, inter, extra, converter.deserialize<TEvent>(frame))
}
}
catch(e: ClosedReceiveChannelException) {}
finally {
onClose(params, inter, extra)
}
}
catch(e: RouteError.CSERouteError) {
close(CloseReason(e.code, e.message ?: "Unknown error"))
}
@@ -310,6 +315,7 @@ inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.wrapAuthCSE
api: CSERoute<TParams, TEvent>,
noinline verifyUser: suspend (TParams, User) -> Unit,
noinline setup: suspend (TParams, User) -> TExtra,
noinline onClose: suspend (TParams, User, TExtra) -> Unit,
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
) = wrapCSE<TParams, TEvent, User, TExtra>(
api = api,
@@ -327,28 +333,32 @@ inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.wrapAuthCSE
user
},
setup = setup,
onClose = onClose,
handler = handler
)
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cse(
api: CSERoute<TParams, TEvent>,
noinline setup: suspend (TParams, Unit) -> TExtra,
noinline setup: suspend (TParams) -> TExtra,
noinline onClose: suspend (TParams, TExtra) -> Unit,
noinline handler: suspend (TParams, TExtra, TEvent) -> Unit
) {
if(api.elevation != Elevation.UN_AUTH) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
wrapCSE(api, { _, _ -> }, setup) { params, _, extra, event -> handler(params, extra, event) }
wrapCSE(api, { _, _ -> }, { it, _ -> setup(it) }, { p, _, extra -> onClose(p, extra) }) { params, _, extra, event -> handler(params, extra, event) }
}
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cseAuth(
api: CSERoute<TParams, TEvent>, noinline verifyUser: suspend (TParams, User) -> Unit,
noinline setup: suspend (TParams, User) -> TExtra, noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
noinline setup: suspend (TParams, User) -> TExtra, noinline onClose: suspend (TParams, User, TExtra) -> Unit,
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
) {
if(api.elevation != Elevation.AUTH) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
wrapAuthCSE(api, verifyUser, setup, handler)
wrapAuthCSE(api, verifyUser, setup, onClose, handler)
}
inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cseAdmin(
api: CSERoute<TParams, TEvent>, noinline setup: suspend (TParams, User) -> TExtra,
noinline onClose: suspend (TParams, User, TExtra) -> Unit,
noinline handler: suspend (TParams, User, TExtra, TEvent) -> Unit
) {
if(api.elevation != Elevation.ADMIN) throw IllegalArgumentException("CSE ${api.pattern} can only be used with ${api.elevation}")
@@ -356,5 +366,5 @@ inline fun <reified TParams: Any, reified TEvent: Any, TExtra> Route.cseAdmin(
if(!user.isAdmin) {
throw RouteError.CSERouteError("Admin access required", CloseReasons.NOT_AUTHORIZED)
}
}, setup, handler)
}, setup, onClose, handler)
}
@@ -402,6 +402,14 @@ object ProjectHandler {
}
}
suspend fun monitorTeardown(
id: Uuid, user: User,
bus: SSEBus.MonitorSSEBus<ServerMonitorEvent, ServerMonitorEvent.ApplicationStart, ServerMonitorEvent.Message,
ServerMonitorEvent.ApplicationEnd, ServerMonitorEvent.Backlog>
) {
bus.end(ServerMonitorEvent.ApplicationEnd(Clock.System.now(), -1))
}
fun deleteMonitor(user: User, req: Uuid) = transaction {
checkMigration(user)
accessibleProject(user, req, true)