- DevLogStore: centralized log store with Flow-based observation (INFO/WARN/ERROR/EXCEPTION levels) - DevHttpInterceptor: capture request/response pairs with timing and body details - DevPanel: bottom panel with Logs/Network/Errors tabs, expandable entries, JSON formatting - Integrated into BricksHttp (all HTTP methods), BricksParser, ActionDispatcher, Main - Move Main.kt from shared/ to test/generic-client/ (library module should not have main) - Add test/generic-client/ as generic bricks-mp desktop host with DevMode toggle - Add kotlinx-datetime dependency for timestamp handling - Add materialIconsExtended for DevPanel icons
514 lines
18 KiB
Kotlin
514 lines
18 KiB
Kotlin
package com.bricks.mp.dev
|
|
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.horizontalScroll
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
import androidx.compose.material.icons.automirrored.filled.ArrowRight
|
|
import androidx.compose.material.icons.filled.BugReport
|
|
import androidx.compose.material.icons.filled.Close
|
|
import androidx.compose.material.icons.filled.ContentCopy
|
|
import androidx.compose.material.icons.filled.ErrorOutline
|
|
import androidx.compose.material.icons.filled.Info
|
|
import androidx.compose.material.icons.filled.NetworkCheck
|
|
import androidx.compose.material.icons.filled.Warning
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.LocalContentColor
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Tab
|
|
import androidx.compose.material3.TabRow
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.collectAsState
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
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.graphics.Color
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import kotlinx.datetime.Clock
|
|
import kotlinx.datetime.Instant
|
|
|
|
/**
|
|
* DevMode panel with tabs: Logs | Network | Errors.
|
|
* Renders as a bottom sheet that can be toggled on/off.
|
|
*/
|
|
@Composable
|
|
fun DevPanel(
|
|
modifier: Modifier = Modifier,
|
|
onClose: () -> Unit = {}
|
|
) {
|
|
var selectedTab by remember { mutableIntStateOf(0) }
|
|
val tabs = listOf("Logs", "Network", "Errors")
|
|
val errorCount = DevLogStore.errorCount()
|
|
val httpErrorCount = DevHttpInterceptor.errorCount()
|
|
|
|
Surface(
|
|
modifier = modifier.fillMaxSize(),
|
|
color = Color(0xFF1E1E1E),
|
|
tonalElevation = 8.dp
|
|
) {
|
|
Column(modifier = Modifier.fillMaxSize()) {
|
|
// Header bar
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Icon(
|
|
Icons.Default.BugReport,
|
|
contentDescription = null,
|
|
tint = Color(0xFF4FC3F7),
|
|
modifier = Modifier.width(20.dp).height(20.dp)
|
|
)
|
|
Text(
|
|
"Dev Mode",
|
|
style = MaterialTheme.typography.titleSmall,
|
|
color = Color(0xFFE0E0E0),
|
|
modifier = Modifier.padding(start = 4.dp)
|
|
)
|
|
}
|
|
Row {
|
|
IconButton(onClick = {
|
|
DevLogStore.clear()
|
|
DevHttpInterceptor.clear()
|
|
}) {
|
|
Icon(
|
|
Icons.Default.Close,
|
|
contentDescription = "Clear",
|
|
tint = Color(0xFF9E9E9E),
|
|
modifier = Modifier.width(18.dp).height(18.dp)
|
|
)
|
|
}
|
|
IconButton(onClick = onClose) {
|
|
Icon(
|
|
Icons.Default.Close,
|
|
contentDescription = "Close",
|
|
tint = Color(0xFF9E9E9E),
|
|
modifier = Modifier.width(18.dp).height(18.dp)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tabs
|
|
TabRow(selectedTabIndex = selectedTab) {
|
|
tabs.forEachIndexed { index, label ->
|
|
Tab(
|
|
selected = selectedTab == index,
|
|
onClick = { selectedTab = index },
|
|
text = {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(label, fontSize = 12.sp)
|
|
if (index == 2 && errorCount > 0) {
|
|
Text(
|
|
" ($errorCount)",
|
|
fontSize = 10.sp,
|
|
color = Color(0xFFD32F2F)
|
|
)
|
|
}
|
|
if (index == 1 && httpErrorCount > 0) {
|
|
Text(
|
|
" ($httpErrorCount)",
|
|
fontSize = 10.sp,
|
|
color = Color(0xFFD32F2F)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
// Tab content
|
|
when (selectedTab) {
|
|
0 -> LogsTab()
|
|
1 -> NetworkTab()
|
|
2 -> ErrorsTab()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun LogsTab() {
|
|
val entries by DevLogStore.entries.collectAsState(initial = emptyList())
|
|
val scrollState = rememberScrollState()
|
|
|
|
if (entries.isEmpty()) {
|
|
Box(
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Text("No logs yet", color = Color(0xFF757575), fontSize = 14.sp)
|
|
}
|
|
return
|
|
}
|
|
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.verticalScroll(scrollState)
|
|
) {
|
|
entries.reversed().forEach { entry ->
|
|
LogEntryRow(entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun LogEntryRow(entry: DevLogEntry) {
|
|
var expanded by remember(entry.id) { mutableStateOf(false) }
|
|
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable { expanded = !expanded }
|
|
.padding(horizontal = 8.dp, vertical = 2.dp)
|
|
) {
|
|
// Summary row
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
|
) {
|
|
Icon(
|
|
imageVector = when (entry.level) {
|
|
DevLogLevel.INFO -> Icons.Default.Info
|
|
DevLogLevel.WARN -> Icons.Default.Warning
|
|
DevLogLevel.ERROR -> Icons.Default.ErrorOutline
|
|
DevLogLevel.EXCEPTION -> Icons.Default.ErrorOutline
|
|
},
|
|
contentDescription = entry.level.label(),
|
|
tint = Color(entry.level.badgeColor()),
|
|
modifier = Modifier.width(14.dp).height(14.dp)
|
|
)
|
|
|
|
Text(
|
|
text = entry.timestamp.toDisplayTime(),
|
|
color = Color(0xFF9E9E9E),
|
|
fontSize = 10.sp,
|
|
fontFamily = FontFamily.Monospace,
|
|
modifier = Modifier.width(70.dp)
|
|
)
|
|
|
|
// Source badge
|
|
Text(
|
|
text = entry.source.label,
|
|
color = Color(0xFFBDBDBD),
|
|
fontSize = 9.sp,
|
|
fontFamily = FontFamily.Monospace,
|
|
modifier = Modifier
|
|
.background(Color(0xFF333333))
|
|
.padding(horizontal = 4.dp, vertical = 1.dp)
|
|
.width(48.dp),
|
|
maxLines = 1
|
|
)
|
|
|
|
// Level badge
|
|
Text(
|
|
text = entry.level.label(),
|
|
color = Color(entry.level.badgeColor()),
|
|
fontSize = 9.sp,
|
|
fontWeight = FontWeight.Bold,
|
|
fontFamily = FontFamily.Monospace,
|
|
modifier = Modifier
|
|
.background(Color(0xFF222222))
|
|
.padding(horizontal = 4.dp, vertical = 1.dp)
|
|
.width(36.dp),
|
|
maxLines = 1
|
|
)
|
|
|
|
Text(
|
|
text = entry.message,
|
|
color = Color(0xFFE0E0E0),
|
|
fontSize = 12.sp,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
|
|
Icon(
|
|
imageVector = if (expanded) Icons.Default.ArrowDropDown else Icons.AutoMirrored.Filled.ArrowRight,
|
|
contentDescription = null,
|
|
tint = Color(0xFF757575),
|
|
modifier = Modifier.width(14.dp).height(14.dp)
|
|
)
|
|
}
|
|
|
|
// Expanded details
|
|
if (expanded) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(start = 28.dp, top = 2.dp, bottom = 4.dp, end = 8.dp)
|
|
) {
|
|
if (entry.details != null) {
|
|
DevCodeBlock(entry.details)
|
|
}
|
|
if (entry.stackTrace != null) {
|
|
DevCodeBlock(entry.stackTrace, color = Color(0xFFFF8A80))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun NetworkTab() {
|
|
val entries by DevHttpInterceptor.entries.collectAsState(initial = emptyList())
|
|
val scrollState = rememberScrollState()
|
|
|
|
if (entries.isEmpty()) {
|
|
Box(
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Text("No HTTP requests yet", color = Color(0xFF757575), fontSize = 14.sp)
|
|
}
|
|
return
|
|
}
|
|
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.verticalScroll(scrollState)
|
|
) {
|
|
entries.reversed().forEach { entry ->
|
|
HttpEntryRow(entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun HttpEntryRow(entry: DevHttpEntry) {
|
|
var expanded by remember(entry.id) { mutableStateOf(false) }
|
|
|
|
val statusColor = when {
|
|
entry.isSuccess -> Color(0xFF4CAF50)
|
|
entry.responseStatus != null && entry.responseStatus!! in 300..399 -> Color(0xFFFFA000)
|
|
entry.responseStatus != null && entry.responseStatus!! >= 400 -> Color(0xFFD32F2F)
|
|
else -> Color(0xFF9E9E9E)
|
|
}
|
|
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable { expanded = !expanded }
|
|
.padding(horizontal = 8.dp, vertical = 2.dp)
|
|
) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.NetworkCheck,
|
|
contentDescription = null,
|
|
tint = statusColor,
|
|
modifier = Modifier.width(14.dp).height(14.dp)
|
|
)
|
|
|
|
Text(
|
|
text = entry.timestamp.toDisplayTime(),
|
|
color = Color(0xFF9E9E9E),
|
|
fontSize = 10.sp,
|
|
fontFamily = FontFamily.Monospace,
|
|
modifier = Modifier.width(70.dp)
|
|
)
|
|
|
|
// Method badge
|
|
Text(
|
|
text = entry.method,
|
|
color = Color(0xFF4FC3F7),
|
|
fontSize = 9.sp,
|
|
fontWeight = FontWeight.Bold,
|
|
fontFamily = FontFamily.Monospace,
|
|
modifier = Modifier
|
|
.background(Color(0xFF222222))
|
|
.padding(horizontal = 4.dp, vertical = 1.dp)
|
|
.width(40.dp),
|
|
maxLines = 1
|
|
)
|
|
|
|
// Status badge
|
|
val statusText = if (entry.error != null) "ERR" else "${entry.responseStatus ?: "..."}"
|
|
Text(
|
|
text = statusText,
|
|
color = statusColor,
|
|
fontSize = 9.sp,
|
|
fontWeight = FontWeight.Bold,
|
|
fontFamily = FontFamily.Monospace,
|
|
modifier = Modifier
|
|
.background(Color(0xFF222222))
|
|
.padding(horizontal = 4.dp, vertical = 1.dp)
|
|
.width(36.dp),
|
|
maxLines = 1
|
|
)
|
|
|
|
// Duration
|
|
if (entry.durationMs != null) {
|
|
Text(
|
|
text = "${entry.durationMs}ms",
|
|
color = Color(0xFFBDBDBD),
|
|
fontSize = 10.sp,
|
|
fontFamily = FontFamily.Monospace,
|
|
modifier = Modifier.width(60.dp)
|
|
)
|
|
}
|
|
|
|
Text(
|
|
text = entry.url,
|
|
color = Color(0xFFE0E0E0),
|
|
fontSize = 12.sp,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
|
|
Icon(
|
|
imageVector = if (expanded) Icons.Default.ArrowDropDown else Icons.AutoMirrored.Filled.ArrowRight,
|
|
contentDescription = null,
|
|
tint = Color(0xFF757575),
|
|
modifier = Modifier.width(14.dp).height(14.dp)
|
|
)
|
|
}
|
|
|
|
// Expanded details
|
|
if (expanded) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(start = 28.dp, top = 2.dp, bottom = 4.dp, end = 8.dp)
|
|
) {
|
|
Text(
|
|
"URL: ${entry.url}",
|
|
color = Color(0xFFBDBDBD),
|
|
fontSize = 11.sp,
|
|
modifier = Modifier.padding(vertical = 2.dp)
|
|
)
|
|
if (entry.requestBody != null) {
|
|
Text("Request Body:", color = Color(0xFF4FC3F7), fontSize = 11.sp, fontWeight = FontWeight.Medium)
|
|
DevCodeBlock(entry.formatBody(entry.requestBody))
|
|
}
|
|
if (entry.responseBody != null) {
|
|
Text("Response Body:", color = Color(0xFF4CAF50), fontSize = 11.sp, fontWeight = FontWeight.Medium)
|
|
DevCodeBlock(entry.formatBody(entry.responseBody))
|
|
}
|
|
if (entry.error != null) {
|
|
Text("Error:", color = Color(0xFFD32F2F), fontSize = 11.sp, fontWeight = FontWeight.Medium)
|
|
DevCodeBlock(entry.error, color = Color(0xFFFF8A80))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ErrorsTab() {
|
|
val entries by DevLogStore.logsByLevel(DevLogLevel.ERROR).collectAsState(initial = emptyList())
|
|
val scrollState = rememberScrollState()
|
|
|
|
if (entries.isEmpty()) {
|
|
Box(
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Text("No errors", color = Color(0xFF4CAF50), fontSize = 14.sp)
|
|
}
|
|
return
|
|
}
|
|
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.verticalScroll(scrollState)
|
|
) {
|
|
entries.reversed().forEach { entry ->
|
|
LogEntryRow(entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DevCodeBlock(
|
|
text: String,
|
|
color: Color = Color(0xFFBDBDBD)
|
|
) {
|
|
var copied by remember { mutableStateOf(false) }
|
|
Card(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(vertical = 2.dp),
|
|
colors = CardDefaults.cardColors(containerColor = Color(0xFF111111))
|
|
) {
|
|
Box(modifier = Modifier.padding(6.dp)) {
|
|
Column {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.End
|
|
) {
|
|
IconButton(
|
|
onClick = {
|
|
// On desktop, we can't easily copy to clipboard without platform-specific code
|
|
// So we just show a brief "copied" indicator
|
|
copied = true
|
|
},
|
|
modifier = Modifier.width(20.dp).height(20.dp)
|
|
) {
|
|
Icon(
|
|
Icons.Default.ContentCopy,
|
|
contentDescription = "Copy",
|
|
tint = if (copied) Color(0xFF4CAF50) else Color(0xFF757575),
|
|
modifier = Modifier.width(12.dp).height(12.dp)
|
|
)
|
|
}
|
|
}
|
|
Text(
|
|
text = text,
|
|
color = color,
|
|
fontSize = 10.sp,
|
|
fontFamily = FontFamily.Monospace,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.horizontalScroll(rememberScrollState())
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reset copied state after delay
|
|
if (copied) {
|
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
|
kotlinx.coroutines.delay(1500)
|
|
copied = false
|
|
}
|
|
}
|
|
}
|