Script monitoring

This commit is contained in:
2026-09-01 11:47:10 +02:00
parent 8da391b6ce
commit 85902620da
37 changed files with 803 additions and 71 deletions
+26 -2
View File
@@ -5,10 +5,11 @@ plugins {
application
alias(libs.plugins.serialization)
alias(libs.plugins.shadow)
alias(libs.plugins.buildconfig)
}
group = "com.jaytux.phoebench"
version = PhoebenchVersion(1, 1, 1)
version = rootProject.version as PhoebenchVersion
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("CLI Client version must be at least as high as protocol/common version")
@@ -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,24 @@ kotlin {
}
tasks.withType<ShadowJar> {
archiveFileName = "phoebench-cli.jar"
archiveFileName = if(isDebug) "phoebench-cli-debug.jar" else "phoebench-cli.jar"
}
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")
}
}
@@ -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}")
}) {}
}
}
@@ -10,7 +10,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 {
+1 -18
View File
@@ -13,7 +13,7 @@ plugins {
alias(libs.plugins.buildconfig)
}
version = PhoebenchVersion(1, 1, 2)
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 }
@@ -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
@@ -144,4 +151,16 @@ fun List<Float>.geomean(): Float {
var prod = 1.0f
for(value in this) prod *= value
return prod.pow(1.0f / size.toFloat())
}
@Composable
fun TextStyle.makeMonospaced(): TextStyle {
val family = FontFamily(
Font(Res.font.JetBrainsMono_Regular, weight = FontWeight.Normal, style = FontStyle.Normal),
Font(Res.font.JetBrainsMono_Bold, weight = FontWeight.Bold, style = FontStyle.Normal),
Font(Res.font.JetBrainsMono_Italic, weight = FontWeight.Normal, style = FontStyle.Italic),
Font(Res.font.JetBrainsMono_BoldItalic, weight = FontWeight.Bold, style = FontStyle.Italic)
)
return copy(fontFamily = family)
}
@@ -38,6 +38,8 @@ interface IProjectRepo {
unit: TimeUnit, input: String, hardware: String): Either<ErrorResponse, EntryResponse>
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
suspend fun deleteMonitorLogs(): Either<ErrorResponse, Unit>
companion object {
class Default(private val _client: Client, private val _projectId: Uuid) : IProjectRepo {
override suspend fun get(): Either<ErrorResponse, ProjectResponse> =
@@ -74,6 +76,9 @@ interface IProjectRepo {
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Entry.delete, id).ignoreValue()
override suspend fun deleteMonitorLogs(): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Project.rmMonitor, _projectId).ignoreValue()
}
fun default(client: Client, projectId: Uuid) = Default(client, projectId)
@@ -7,12 +7,14 @@ import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.ServerMonitorEvent
import kotlin.uuid.Uuid
interface ISSERepo {
suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectMonitor(id: Uuid, onEvent: suspend (Either<ErrorResponse, ServerMonitorEvent>) -> Unit): Either<ErrorResponse, Unit>
companion object {
class Default(private val _client: Client) : ISSERepo {
@@ -24,6 +26,9 @@ interface ISSERepo {
override suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit> =
_client.callSSE(Routes.SSE.projectSpecific, id, onEvent)
override suspend fun connectMonitor(id: Uuid, onEvent: suspend (Either<ErrorResponse, ServerMonitorEvent>) -> Unit): Either<ErrorResponse, Unit> =
_client.callSSE(Routes.SSE.monitor, id, onEvent)
}
fun default(client: Client) = Default(client)
@@ -1,5 +1,6 @@
package com.jaytux.phoebench.clients.data
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
@@ -18,7 +19,9 @@ import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ServerMonitorEvent
import com.jaytux.phoebench.common.TimeUnit
import com.jaytux.phoebench.common.fold
import kotlinx.coroutines.Job
import kotlin.time.Clock
import kotlin.time.Instant
@@ -87,6 +90,7 @@ class ProjectVM(
private val _currentBenchmark = mutableStateOf(0)
private val _inputs = mutableStateOf(setOf<String>())
private val _hardware = mutableStateOf(setOf<String>())
private val _monitorMessages = mutableStateOf<List<ServerMonitorEvent.ITextEvent>?>(null)
val name = _name.immutable()
val owner = _owner.immutable()
@@ -97,8 +101,10 @@ class ProjectVM(
val currentBenchmark = _currentBenchmark.immutable()
val inputs = _inputs.immutable()
val hardware = _hardware.immutable()
val monitorMessages = _monitorMessages.immutable()
private var _job: Job? = null
private var _monitorJob: Job? = null
init {
_job = withScope {
@@ -221,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
}
@@ -279,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()
}
}
@@ -18,12 +18,14 @@ 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.*
@@ -57,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) }
@@ -64,16 +67,20 @@ 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) {
Spacer(Modifier.width(25.dp))
IconButton({ editing = true }) {
Icon(Lucide.Pencil, "Edit project details")
Text("Project ${name ?: "Unnamed Project"}", style = MaterialTheme.typography.headlineMedium)
if (editable) {
Spacer(Modifier.width(25.dp))
IconButton({ editing = true }) {
Icon(Lucide.Pencil, "Edit project details")
}
IconButton({ deleting = true }) {
Icon(Lucide.Trash, "Delete project")
}
IconButton({ vm.openMonitor() }) {
Icon(Lucide.SquareTerminal, "Open monitor")
}
}
IconButton({ deleting = true }) {
Icon(Lucide.Trash, "Delete project")
}
}
}
owner?.let { Text("${if(public) "Public" else "Private"} project by user $it") }
Spacer(Modifier.height(15.dp))
@@ -112,6 +119,82 @@ 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_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
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
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
@@ -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
@@ -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
@@ -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>