v0.9.0: connection testing feature

- ConnectionTester: TCP port connectivity test with latency
- Test button (📡) on each saved connection item
- Visual result: reachable (green) or error (red)
- Async test via coroutine (no UI freeze)
This commit is contained in:
茂之钳
2026-05-24 01:32:44 +00:00
parent 4707675c28
commit ae4543ec21
2 changed files with 87 additions and 0 deletions
@@ -0,0 +1,58 @@
package ai.openclaw.app.ui.tergent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.InetSocketAddress
import java.net.Socket
/**
* 连接测试工具:测试目标主机端口是否可达
*/
object ConnectionTester {
data class TestResult(
val host: String,
val port: Int,
val reachable: Boolean,
val latencyMs: Long? = null,
val error: String? = null
)
/**
* 测试 TCP 端口连通性
* @param host 主机地址
* @param port 端口
* @param timeoutMs 超时毫秒(默认 5000
*/
suspend fun testConnection(host: String, port: Int, timeoutMs: Int = 5000): TestResult =
withContext(Dispatchers.IO) {
val start = System.currentTimeMillis()
try {
val socket = Socket()
socket.connect(InetSocketAddress(host, port), timeoutMs)
socket.close()
val latency = System.currentTimeMillis() - start
TestResult(host, port, reachable = true, latencyMs = latency)
} catch (e: Exception) {
TestResult(
host, port, reachable = false,
error = when {
e.message?.contains("Timeout", ignoreCase = true) == true -> "连接超时"
e.message?.contains("refused", ignoreCase = true) == true -> "连接被拒绝"
e.message?.contains("resolve", ignoreCase = true) == true -> "无法解析主机名"
else -> e.message ?: "未知错误"
}
)
}
}
/**
* 格式化测试结果显示
*/
fun formatResult(result: TestResult): String {
return when {
result.reachable -> "${result.host}:${result.port} (${result.latencyMs}ms)"
else -> "${result.host}:${result.port}${result.error}"
}
}
}
@@ -3,6 +3,7 @@ package ai.openclaw.app.ui.tergent
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import kotlinx.coroutines.launch
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
@@ -303,6 +304,9 @@ private fun ColumnScope.ConnectionListView(
@Composable @Composable
private fun SavedConnectionItem(config: SshConfig, onConnect: () -> Unit, onDelete: () -> Unit) { private fun SavedConnectionItem(config: SshConfig, onConnect: () -> Unit, onDelete: () -> Unit) {
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var testResult by remember { mutableStateOf<ConnectionTester.TestResult?>(null) }
var testing by remember { mutableStateOf(false) }
val testScope = rememberCoroutineScope()
Surface( Surface(
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp).clickable { onConnect() }, modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp).clickable { onConnect() },
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
@@ -314,6 +318,31 @@ private fun SavedConnectionItem(config: SshConfig, onConnect: () -> Unit, onDele
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(config.displayName.ifEmpty { config.host }, color = textPrimary, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(config.displayName.ifEmpty { config.host }, color = textPrimary, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${config.username}@${config.host}:${config.port}", color = textSecondary, fontSize = 11.sp, fontFamily = FontFamily.Monospace) Text("${config.username}@${config.host}:${config.port}", color = textSecondary, fontSize = 11.sp, fontFamily = FontFamily.Monospace)
if (testResult != null) {
Text(
text = ConnectionTester.formatResult(testResult!!),
color = if (testResult!!.reachable) accent else danger,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace
)
} else if (testing) {
Text("⏳ 测试中...", color = textTertiary, fontSize = 10.sp, fontFamily = FontFamily.Monospace)
}
}
// 测试按钮
IconButton(
onClick = {
testing = true
testResult = null
testScope.launch {
testResult = ConnectionTester.testConnection(config.host, config.port)
testing = false
}
},
modifier = Modifier.size(28.dp),
enabled = !testing
) {
Text(if (testing) "" else "📡", fontSize = 12.sp)
} }
FilledTonalButton( FilledTonalButton(
onClick = onConnect, onClick = onConnect,