yumoqing 43f416a6f0 feat: add DevMode with logging, HTTP interception, and debug panel
- 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
2026-05-19 23:15:37 +08:00

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
}
}
}