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:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user