6 Commits
Author SHA1 Message Date
jaytux 8da391b6ce Web UI: width detection 2026-08-30 21:24:56 +02:00
jaytux bc9861900e UI clients: fix narrow vs wide UI 2026-08-30 21:11:18 +02:00
jaytux 1d10802140 CLI: formatting 2026-08-27 15:23:07 +02:00
jaytux 62f952ac68 CORS headers for SSE 2026-08-27 14:29:27 +02:00
jaytux c662626bd8 Server version bump 2026-08-27 14:11:19 +02:00
jaytux e40afb2c3d Server: CORS support on SSE routes 2026-08-27 14:10:04 +02:00
14 changed files with 674 additions and 272 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ plugins {
}
group = "com.jaytux.phoebench"
version = rootProject.version as PhoebenchVersion
version = PhoebenchVersion(1, 1, 1)
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("CLI Client version must be at least as high as protocol/common version")
@@ -78,7 +78,7 @@ object ProjectHandlers {
println("\n${it.benchmarks.size} benchmarks:")
it.benchmarks.forEach { b ->
println(" - [${b.id}] ${b.name} with ${b.entries.size} entries:")
println(" - [${b.id}] BENCHMARK ${b.name} (with ${b.entries.size} entries):")
b.entries.forEach { e ->
println(" - [${e.id}] ENTRY versioned ${map[e.versionId]?.name ?: "<invalid version>"} at ${e.timestamp.fmt()} " +
"(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})")
+1 -1
View File
@@ -13,7 +13,7 @@ plugins {
alias(libs.plugins.buildconfig)
}
version = PhoebenchVersion(1, 1, 1)
version = PhoebenchVersion(1, 1, 2)
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("UI Clients version must be at least as high as protocol/common version")
@@ -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
@@ -133,7 +133,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 })) }
@@ -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 }
@@ -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
)
@@ -20,6 +20,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
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
@@ -29,6 +30,9 @@ 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
@@ -150,7 +154,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 +164,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 +353,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 +534,9 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
val allInputs by vm.inputs
val allHardware by vm.hardware
val visibleInputs by remember(entries, allInputs) { mutableStateOf(allInputs intersect entries.map { it.input }.toSet()) }
val visibleInputs by remember(entries, allInputs) {
mutableStateOf(allInputs intersect entries.map { it.input }.toSet())
}
var filteredEntries by remember { mutableStateOf(entries) }
var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) }
@@ -552,7 +552,17 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
val enabledHardware = remember(allHardware) { mutableStateSetOf<String>() }
val enabledInputs = remember(allInputs) { mutableStateSetOf<String>() }
LaunchedEffect(entries, allInputs, allHardware, timeFilter, timeMin, timeMax, versionFilterRev, inputFilterRev, filterHardware) {
LaunchedEffect(
entries,
allInputs,
allHardware,
timeFilter,
timeMin,
timeMax,
versionFilterRev,
inputFilterRev,
filterHardware
) {
val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
enabledHardware.clear()
enabledInputs.clear()
@@ -617,15 +627,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 +674,33 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor)
)
Row(Modifier.fillMaxSize().padding(20.dp)) {
Row(Modifier.weight(0.66f).fillMaxHeight()) {
val uiMode = GlobalUiMode.current
var showOptions by remember(uiMode) { mutableStateOf(uiMode == UiMode.WIDE) }
RowOrPanel(useRow = GlobalUiMode.current == UiMode.WIDE, Modifier.fillMaxSize()) {
mainContent { isNarrow, toggleSide ->
Box {
Row(Modifier.padding(end = if (isNarrow) 25.dp else 0.dp)) {
if (entries.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f).padding(35.dp)) {
Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
}
}
else {
Column {
Text("Measurements", style = MaterialTheme.typography.headlineSmall)
} else {
Column(Modifier.padding(35.dp)) {
Row {
Row(Modifier.weight(0.66f), verticalAlignment = Alignment.CenterVertically) {
Text("Measurements", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.width(10.dp))
Text(
if (showOptions) "(hide options)" else "(show options)",
Modifier.clickable { showOptions = !showOptions }.align(Alignment.Bottom),
color = linkColor,
textDecoration = TextDecoration.Underline
)
}
if (showOptions) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("View as: ", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(5.dp))
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
@@ -675,45 +708,56 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
SegmentedButton(
currentUnit == it,
{ currentUnit = it },
shape = SegmentedButtonDefaults.itemShape(idx, TimeUnit.entries.size)
shape = SegmentedButtonDefaults.itemShape(
idx,
TimeUnit.entries.size
)
) {
Text(it.disp)
}
}
}
}
Spacer(Modifier.width(50.dp))
Row(Modifier.weight(0.33f), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(5.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Display: ", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(5.dp))
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
SegmentedButton(!displayWarmups, { displayWarmups = false },
SegmentedButton(
!displayWarmups, { displayWarmups = false },
shape = SegmentedButtonDefaults.itemShape(0, 2)
) {
Text("(Steady-state) measurements")
}
SegmentedButton(displayWarmups, { displayWarmups = true },
SegmentedButton(
displayWarmups, { displayWarmups = true },
shape = SegmentedButtonDefaults.itemShape(1, 2)
) {
Text("Warmup measurements")
}
}
}
}
Spacer(Modifier.height(5.dp))
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Filter by timestamp: ", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(5.dp))
RangeSlider(timeFilter, { timeFilter = it }, Modifier.fillMaxWidth(), valueRange = 0f..1f)
RangeSlider(
timeFilter,
{ timeFilter = it },
Modifier.fillMaxWidth(),
valueRange = 0f..1f
)
}
Text(timeFilterString, Modifier.align(Alignment.End))
}
}
}
Spacer(Modifier.height(15.dp))
if (!isNarrow || baselineComparison == null) {
XYGraph(
rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()),
rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()),
@@ -735,24 +779,49 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
}
}
}
else baselineComparison?.let { blc ->
baselineGraph(baseline, baselineMax, style, gridStyle, blc, currentUnit)
}
}
}
}
Spacer(Modifier.width(20.dp))
if (isNarrow) {
ExpandableSidePanel(
Modifier.width(70.dp).fillMaxHeight().align(Alignment.CenterEnd),
toggleSide = toggleSide, elevation = 10.dp
)
}
}
Column(Modifier.weight(0.33f).fillMaxHeight()) {
Row {
sideContent(false) { isNarrow, toggleSide ->
val wrapper: @Composable (@Composable () -> Unit) -> Unit = if (isNarrow) { it ->
ExpandedSidePanel(Modifier.fillMaxWidth(0.8f), toggleSide = toggleSide) {
Box(Modifier.padding(10.dp)) { it() }
}
} else { it -> it() }
wrapper {
Column(Modifier.fillMaxHeight()) {
Row(Modifier.weight(0.33f)) {
Column(Modifier.weight(0.5f)) {
Text("Versions", style = MaterialTheme.typography.headlineSmall)
Text("Click a version to toggle its visibility.", fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall)
Text(
"Click a version to toggle its visibility.",
fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(alpha = 0.5f),
style = MaterialTheme.typography.bodySmall
)
Spacer(Modifier.height(10.dp))
if (versions.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
Text("No versions yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
Text(
"No versions yet.",
Modifier.align(Alignment.Center),
fontStyle = FontStyle.Italic
)
}
}
else {
} else {
LazyColumn(Modifier.padding(start = 5.dp)) {
items(versions.toList().sortedBy { it.second.name }) { (_, lbl) ->
Box(Modifier.fillMaxWidth().clickable {
@@ -760,8 +829,7 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
if (versionFilter.size == versions.size) {
versionFilter.clear()
versionFilter.add(lbl)
}
else {
} else {
versionFilter.toggle(lbl)
if (versionFilter.isEmpty()) versionFilter.addAll(versions.values)
}
@@ -781,22 +849,38 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
}
}
if (editable) {
IconButton({ editingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) {
IconButton(
{ editingVersion = lbl },
Modifier.align(Alignment.CenterVertically)
) {
Icon(Lucide.Pencil, "Edit version")
}
}
IconButton({ baseline = if(baseline == lbl) null else lbl },
Modifier.align(Alignment.CenterVertically), enabled = lbl in versionFilter) {
IconButton(
{ baseline = if (baseline == lbl) null else lbl },
Modifier.align(Alignment.CenterVertically),
enabled = lbl in versionFilter
) {
if (baseline == lbl) {
Surface(Modifier.padding(5.dp), color = MaterialTheme.colorScheme.inverseSurface, shape = MaterialTheme.shapes.large) {
Icon(Lucide.RulerDimensionLine, "Use as baseline", Modifier.padding(5.dp))
Surface(
Modifier.padding(5.dp),
color = MaterialTheme.colorScheme.inverseSurface,
shape = MaterialTheme.shapes.large
) {
Icon(
Lucide.RulerDimensionLine,
"Use as baseline",
Modifier.padding(5.dp)
)
}
}
else Icon(Lucide.RulerDimensionLine, "Use as baseline")
} else Icon(Lucide.RulerDimensionLine, "Use as baseline")
}
if (editable) {
IconButton({ deletingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) {
IconButton(
{ deletingVersion = lbl },
Modifier.align(Alignment.CenterVertically)
) {
Icon(Lucide.Trash, "Delete version")
}
}
@@ -810,8 +894,12 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
LazyColumn(Modifier.weight(0.5f)) {
item { Text("Inputs", style = MaterialTheme.typography.headlineSmall) }
item {
Text("Click an input to set or clear the filter.", fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall)
Text(
"Click an input to set or clear the filter.",
fontStyle = FontStyle.Italic,
color = LocalContentColor.current.copy(0.5f),
style = MaterialTheme.typography.bodySmall
)
}
item { Spacer(Modifier.height(10.dp)) }
@@ -821,18 +909,24 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
if (inputFilter.size == allInputs.size) {
inputFilter.clear()
inputFilter.add(it)
}
else if(inputFilter.size == 1) {
} else if (inputFilter.size == 1) {
inputFilter.addAll(allInputs.toSet()); inputFilter.remove(it)
}
else {
} else {
inputFilter.toggle(it)
if (inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
}
}) {
Text(it, Modifier.padding(start = 5.dp), color = LocalContentColor.current.copy(alpha = if(it in enabledInputs) 1.0f else 0.9f))
if(it !in enabledInputs) Text(" (no data)", Modifier.align(Alignment.Bottom),
style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(0.9f))
Text(
it,
Modifier.padding(start = 5.dp),
color = LocalContentColor.current.copy(alpha = if (it in enabledInputs) 1.0f else 0.9f)
)
if (it !in enabledInputs) Text(
" (no data)",
Modifier.align(Alignment.Bottom),
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(0.9f)
)
}
}
@@ -841,13 +935,16 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
if (inputFilter.size == allInputs.size) {
inputFilter.clear()
inputFilter.add(it)
}
else {
} else {
inputFilter.toggle(it)
if (inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
}
}) {
Text(it, Modifier.padding(start = 5.dp), color = LocalContentColor.current.copy(0.66f))
Text(
it,
Modifier.padding(start = 5.dp),
color = LocalContentColor.current.copy(0.66f)
)
}
}
@@ -857,59 +954,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 +1007,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 +1068,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,69 @@
package com.jaytux.phoebench.clients
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.SwingPanel
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
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 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") {
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()
}
}
}
}
@@ -65,7 +65,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()
}
}
}
@@ -63,7 +63,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 -1
View File
@@ -7,7 +7,7 @@ plugins {
}
group = "com.jaytux.phoebench"
version = PhoebenchVersion(1, 1, 1)
version = PhoebenchVersion(1, 1, 2)
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("Server version must be at least as high as protocol/common version")
@@ -37,20 +37,23 @@ fun Application.module() {
json()
}
install(SSE) {}
val allowLocalhost = environment.config.propertyOrNull("ktor.cors.enableLocalhostOn")?.getString() ?: "0"
val safeOrigin = environment.config.propertyOrNull("ktor.cors.browserOrigin")?.getString()
install(CORS) {
// -- methods --
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
// -- headers --
allowHeader(HttpHeaders.Accept)
allowHeader(HttpHeaders.Pragma)
allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.CacheControl)
allowHeader(HttpHeaders.LastEventID)
// -- exposed headers --
exposeHeader(HttpHeaders.ContentType)
allowCredentials = true
allowNonSimpleContentTypes = true
@@ -73,6 +76,8 @@ fun Application.module() {
}
}
install(SSE) {}
install(StatusPages) {
status(HttpStatusCode.Forbidden) { call, status ->
call.respond(status, ErrorResponse("Access Forbidden: CORS failed."))