tailscale: Add exit node switcher & peer status
This commit is contained in:
@@ -46,6 +46,7 @@ import io.nekohasekai.sfa.compose.screen.tools.OOMReportMetadataScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.OutboundPickerScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.STUNTestScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.TailscaleEndpointScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.TailscaleExitNodePickerScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.TailscalePeerScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel
|
||||
import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen
|
||||
@@ -278,6 +279,19 @@ fun SFANavHost(
|
||||
TailscaleEndpointScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "tools/tailscale/{endpointTag}/exit_node",
|
||||
arguments = listOf(navArgument("endpointTag") { type = NavType.StringType }),
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) { backStackEntry ->
|
||||
val endpointTag = Uri.decode(backStackEntry.arguments?.getString("endpointTag") ?: return@composable)
|
||||
val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel()
|
||||
TailscaleExitNodePickerScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "tools/tailscale/{endpointTag}/peer/{peerId}",
|
||||
arguments = listOf(
|
||||
|
||||
+120
-22
@@ -2,6 +2,7 @@ package io.nekohasekai.sfa.compose.screen.tools
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -93,6 +94,7 @@ fun TailscaleEndpointScreen(
|
||||
) {
|
||||
val hasNetwork = endpoint.networkName.isNotEmpty()
|
||||
val hasMagicDNS = endpoint.magicDNSSuffix.isNotEmpty()
|
||||
val hasExitNode = endpoint.backendState == "Running" && endpoint.hasExitNodeCandidates
|
||||
val hasAuth = endpoint.authURL.isNotEmpty()
|
||||
|
||||
// Status section
|
||||
@@ -106,7 +108,7 @@ fun TailscaleEndpointScreen(
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
val stateIsLast = !hasNetwork && !hasMagicDNS && !hasAuth
|
||||
val stateIsLast = !hasNetwork && !hasMagicDNS && !hasExitNode && !hasAuth
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
@@ -142,7 +144,7 @@ fun TailscaleEndpointScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
if (hasNetwork) {
|
||||
val networkIsLast = !hasMagicDNS && !hasAuth
|
||||
val networkIsLast = !hasMagicDNS && !hasExitNode && !hasAuth
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
@@ -166,7 +168,7 @@ fun TailscaleEndpointScreen(
|
||||
)
|
||||
}
|
||||
if (hasMagicDNS) {
|
||||
val magicDNSIsLast = !hasAuth
|
||||
val magicDNSIsLast = !hasExitNode && !hasAuth
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
@@ -189,6 +191,39 @@ fun TailscaleEndpointScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
if (hasExitNode) {
|
||||
val exitNodeIsLast = !hasAuth
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.tailscale_exit_node),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
endpoint.exitNode?.hostName ?: stringResource(R.string.disabled),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(
|
||||
if (exitNodeIsLast) {
|
||||
RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
|
||||
} else {
|
||||
RoundedCornerShape(0.dp)
|
||||
},
|
||||
)
|
||||
.clickable {
|
||||
navController.navigate(
|
||||
"tools/tailscale/${Uri.encode(endpointTag)}/exit_node",
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
if (hasAuth) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
@@ -323,34 +358,97 @@ private fun PeerItem(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
val badges = peerBadges(peer)
|
||||
val firstIP = peer.tailscaleIPs.firstOrNull()
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (peer.online) Color(0xFF4CAF50) else Color.Gray),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
peer.hostName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
supportingContent = if (peer.tailscaleIPs.isNotEmpty()) {
|
||||
{
|
||||
if (firstIP != null) {
|
||||
Text(
|
||||
peer.tailscaleIPs.first(),
|
||||
firstIP,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (peer.online) Color(0xFF4CAF50) else Color.Gray),
|
||||
)
|
||||
},
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
if (badges.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
for (badge in badges) {
|
||||
PeerBadgeView(badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class PeerBadge(val text: String, val color: Color)
|
||||
|
||||
@Composable
|
||||
private fun peerBadges(peer: TailscalePeerData): List<PeerBadge> {
|
||||
val badges = mutableListOf<PeerBadge>()
|
||||
if (peer.shareeNode) {
|
||||
badges += PeerBadge(stringResource(R.string.tailscale_shared_in), Color(0xFFF44336))
|
||||
}
|
||||
if (peer.exitNodeOption) {
|
||||
badges += PeerBadge(stringResource(R.string.tailscale_exit_node), Color(0xFF2196F3))
|
||||
}
|
||||
when {
|
||||
peer.expired -> {
|
||||
badges += PeerBadge(stringResource(R.string.tailscale_expired), Color(0xFFF44336))
|
||||
}
|
||||
peer.keyExpiry > 0 -> {
|
||||
val expiryMs = peer.keyExpiry * 1000
|
||||
val now = System.currentTimeMillis()
|
||||
val oneMonthMs = 30L * 24 * 60 * 60 * 1000
|
||||
if (expiryMs - now <= oneMonthMs) {
|
||||
val rel = DateUtils.getRelativeTimeSpanString(
|
||||
expiryMs,
|
||||
now,
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE,
|
||||
).toString()
|
||||
badges += PeerBadge(stringResource(R.string.tailscale_expires_relative, rel), Color.Gray)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
badges += PeerBadge(stringResource(R.string.tailscale_key_expiry_disabled), Color.Gray)
|
||||
}
|
||||
}
|
||||
if (peer.sshHostKeys.isNotEmpty()) {
|
||||
badges += PeerBadge(stringResource(R.string.tailscale_ssh), Color(0xFF4CAF50))
|
||||
}
|
||||
return badges
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeerBadgeView(badge: PeerBadge) {
|
||||
Text(
|
||||
text = badge.text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(badge.color)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
package io.nekohasekai.sfa.compose.screen.tools
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TailscaleExitNodePickerScreen(
|
||||
navController: NavController,
|
||||
viewModel: TailscaleStatusViewModel,
|
||||
endpointTag: String,
|
||||
) {
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.tailscale_exit_node)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.content_description_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val endpoint = state.endpoints.firstOrNull { it.endpointTag == endpointTag }
|
||||
|
||||
if (endpoint == null) {
|
||||
LaunchedEffect(Unit) {
|
||||
navController.navigateUp()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var searchText by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
val selfStableID = endpoint.selfPeer?.stableID
|
||||
val candidates = endpoint.userGroups
|
||||
.flatMap { it.peers }
|
||||
.filter { it.exitNodeOption && it.stableID != selfStableID }
|
||||
.filter { searchText.isEmpty() || it.hostName.contains(searchText, ignoreCase = true) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
OutlinedTextField(
|
||||
value = searchText,
|
||||
onValueChange = { searchText = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text(stringResource(android.R.string.search_go)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Search, contentDescription = null)
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
item {
|
||||
DisabledRow(
|
||||
isSelected = endpoint.exitNode == null,
|
||||
onClick = {
|
||||
viewModel.setExitNode(endpointTag, "")
|
||||
navController.navigateUp()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
items(candidates, key = { it.stableID }) { peer ->
|
||||
PeerRow(
|
||||
peer = peer,
|
||||
isSelected = endpoint.exitNode?.stableID == peer.stableID,
|
||||
onClick = {
|
||||
viewModel.setExitNode(endpointTag, peer.stableID)
|
||||
navController.navigateUp()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisabledRow(
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.disabled),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeerRow(
|
||||
peer: TailscalePeerData,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (peer.online) Color(0xFF4CAF50) else Color.Gray),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = peer.hostName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
val firstIP = peer.tailscaleIPs.firstOrNull()
|
||||
if (firstIP != null) {
|
||||
Text(
|
||||
text = firstIP,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -329,47 +328,89 @@ fun TailscalePeerScreen(
|
||||
}
|
||||
|
||||
// Details section
|
||||
val showDetails = peer.keyExpiry > 0 || peer.os.isNotEmpty() || peer.exitNode
|
||||
if (showDetails) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.tailscale_details))
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
),
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.tailscale_details))
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (peer.keyExpiry > 0) {
|
||||
when {
|
||||
peer.expired -> {
|
||||
DetailRow(
|
||||
label = stringResource(R.string.tailscale_key_expiry),
|
||||
value = stringResource(R.string.tailscale_expired),
|
||||
valueColor = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
peer.keyExpiry > 0 -> {
|
||||
val expiryText = DateUtils.getRelativeTimeSpanString(
|
||||
peer.keyExpiry * 1000,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
0,
|
||||
).toString()
|
||||
DetailRow(
|
||||
label = stringResource(R.string.tailscale_key_expiry),
|
||||
value = expiryText,
|
||||
)
|
||||
}
|
||||
if (peer.os.isNotEmpty()) {
|
||||
else -> {
|
||||
DetailRow(
|
||||
label = stringResource(R.string.tailscale_os),
|
||||
value = peer.os,
|
||||
)
|
||||
}
|
||||
if (peer.exitNode) {
|
||||
DetailRow(
|
||||
label = stringResource(R.string.tailscale_exit_node),
|
||||
value = stringResource(R.string.tailscale_active),
|
||||
label = stringResource(R.string.tailscale_key_expiry),
|
||||
value = stringResource(R.string.disabled),
|
||||
valueColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (peer.os.isNotEmpty()) {
|
||||
DetailRow(
|
||||
label = stringResource(R.string.tailscale_os),
|
||||
value = peer.os,
|
||||
)
|
||||
}
|
||||
if (!peer.online && peer.lastSeen > 0) {
|
||||
val lastSeenText = DateUtils.getRelativeTimeSpanString(
|
||||
peer.lastSeen * 1000,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
0,
|
||||
).toString()
|
||||
DetailRow(
|
||||
label = stringResource(R.string.tailscale_last_seen),
|
||||
value = lastSeenText,
|
||||
)
|
||||
}
|
||||
if (peer.exitNode) {
|
||||
DetailRow(
|
||||
label = stringResource(R.string.tailscale_exit_node),
|
||||
value = stringResource(R.string.tailscale_active),
|
||||
)
|
||||
} else if (peer.exitNodeOption) {
|
||||
DetailRow(
|
||||
label = stringResource(R.string.tailscale_exit_node),
|
||||
value = stringResource(R.string.tailscale_available),
|
||||
)
|
||||
}
|
||||
if (peer.shareeNode) {
|
||||
DetailRow(
|
||||
label = stringResource(R.string.tailscale_shared_in),
|
||||
value = stringResource(R.string.tailscale_yes),
|
||||
)
|
||||
}
|
||||
if (peer.sshHostKeys.isNotEmpty()) {
|
||||
DetailRow(
|
||||
label = stringResource(R.string.tailscale_ssh),
|
||||
value = stringResource(R.string.tailscale_available),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,7 +436,7 @@ private fun SectionHeader(title: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailRow(label: String, value: String) {
|
||||
private fun DetailRow(label: String, value: String, valueColor: Color? = null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -410,7 +451,7 @@ private fun DetailRow(label: String, value: String) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
color = valueColor ?: MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
|
||||
+38
-1
@@ -11,17 +11,22 @@ import kotlinx.coroutines.launch
|
||||
|
||||
data class TailscalePeerData(
|
||||
val id: String,
|
||||
val stableID: String,
|
||||
val hostName: String,
|
||||
val dnsName: String,
|
||||
val os: String,
|
||||
val tailscaleIPs: List<String>,
|
||||
val sshHostKeys: List<String>,
|
||||
val online: Boolean,
|
||||
val exitNode: Boolean,
|
||||
val exitNodeOption: Boolean,
|
||||
val shareeNode: Boolean,
|
||||
val expired: Boolean,
|
||||
val active: Boolean,
|
||||
val rxBytes: Long,
|
||||
val txBytes: Long,
|
||||
val keyExpiry: Long,
|
||||
val lastSeen: Long,
|
||||
)
|
||||
|
||||
data class TailscaleUserGroupData(
|
||||
@@ -39,8 +44,18 @@ data class TailscaleEndpointData(
|
||||
val networkName: String,
|
||||
val magicDNSSuffix: String,
|
||||
val selfPeer: TailscalePeerData?,
|
||||
val exitNode: TailscalePeerData?,
|
||||
val userGroups: List<TailscaleUserGroupData>,
|
||||
)
|
||||
) {
|
||||
val hasExitNodeCandidates: Boolean
|
||||
get() {
|
||||
if (exitNode != null) return true
|
||||
val selfStableID = selfPeer?.stableID
|
||||
return userGroups.any { group ->
|
||||
group.peers.any { it.exitNodeOption && it.stableID != selfStableID }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class TailscaleStatusState(
|
||||
val endpoints: List<TailscaleEndpointData> = emptyList(),
|
||||
@@ -92,6 +107,16 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
|
||||
updateState { copy(endpoints = emptyList(), isSubscribed = false) }
|
||||
}
|
||||
|
||||
fun setExitNode(endpointTag: String, stableID: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Libbox.newStandaloneCommandClient().setTailscaleExitNode(endpointTag, stableID)
|
||||
} catch (e: Exception) {
|
||||
sendErrorMessage(e.message ?: "set exit node failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun endpoint(tag: String): TailscaleEndpointData? = currentState.endpoints.firstOrNull { it.endpointTag == tag }
|
||||
|
||||
fun peer(endpointTag: String, peerId: String): TailscalePeerData? {
|
||||
@@ -127,6 +152,7 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
|
||||
userGroups.add(convertUserGroup(groupIterator.next()))
|
||||
}
|
||||
val self = endpoint.getSelf()
|
||||
val exitNode = endpoint.exitNode
|
||||
return TailscaleEndpointData(
|
||||
endpointTag = endpoint.endpointTag,
|
||||
backendState = endpoint.backendState,
|
||||
@@ -134,6 +160,7 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
|
||||
networkName = endpoint.networkName,
|
||||
magicDNSSuffix = endpoint.magicDNSSuffix,
|
||||
selfPeer = if (self != null) convertPeer(self) else null,
|
||||
exitNode = if (exitNode != null) convertPeer(exitNode) else null,
|
||||
userGroups = userGroups,
|
||||
)
|
||||
}
|
||||
@@ -161,20 +188,30 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
|
||||
while (ipIterator.hasNext()) {
|
||||
ips.add(ipIterator.next())
|
||||
}
|
||||
val sshKeys = mutableListOf<String>()
|
||||
val keyIterator = peer.sshHostKeys()
|
||||
while (keyIterator.hasNext()) {
|
||||
sshKeys.add(keyIterator.next())
|
||||
}
|
||||
val dnsName = peer.getDNSName()
|
||||
return TailscalePeerData(
|
||||
id = if (dnsName.isNotEmpty()) dnsName else peer.hostName,
|
||||
stableID = peer.stableID,
|
||||
hostName = peer.hostName,
|
||||
dnsName = dnsName,
|
||||
os = peer.getOS(),
|
||||
tailscaleIPs = ips,
|
||||
sshHostKeys = sshKeys,
|
||||
online = peer.online,
|
||||
exitNode = peer.exitNode,
|
||||
exitNodeOption = peer.exitNodeOption,
|
||||
shareeNode = peer.shareeNode,
|
||||
expired = peer.expired,
|
||||
active = peer.active,
|
||||
rxBytes = peer.rxBytes,
|
||||
txBytes = peer.txBytes,
|
||||
keyExpiry = peer.keyExpiry,
|
||||
lastSeen = peer.lastSeen,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +471,13 @@
|
||||
<string name="tailscale_key_expiry">انقضای کلید</string>
|
||||
<string name="tailscale_exit_node">گره خروجی</string>
|
||||
<string name="tailscale_active">فعال</string>
|
||||
<string name="tailscale_shared_in">به اشتراک گذاشته شده</string>
|
||||
<string name="tailscale_expired">منقضی شده</string>
|
||||
<string name="tailscale_expires_relative">انقضا %1$s</string>
|
||||
<string name="tailscale_key_expiry_disabled">انقضای کلید غیرفعال است</string>
|
||||
<string name="tailscale_last_seen">آخرین بازدید</string>
|
||||
<string name="tailscale_available">در دسترس</string>
|
||||
<string name="tailscale_yes">بله</string>
|
||||
|
||||
<string name="stun_test">تست STUN</string>
|
||||
<string name="stun_server">سرور</string>
|
||||
|
||||
@@ -477,6 +477,13 @@
|
||||
<string name="tailscale_key_expiry">Срок действия ключа</string>
|
||||
<string name="tailscale_exit_node">Выходной узел</string>
|
||||
<string name="tailscale_active">Активен</string>
|
||||
<string name="tailscale_shared_in">Поделено</string>
|
||||
<string name="tailscale_expired">Истёк</string>
|
||||
<string name="tailscale_expires_relative">Истекает %1$s</string>
|
||||
<string name="tailscale_key_expiry_disabled">Срок действия ключа отключён</string>
|
||||
<string name="tailscale_last_seen">Был(а) в сети</string>
|
||||
<string name="tailscale_available">Доступно</string>
|
||||
<string name="tailscale_yes">Да</string>
|
||||
|
||||
<string name="stun_test">STUN-тест</string>
|
||||
<string name="stun_server">Сервер</string>
|
||||
|
||||
@@ -468,6 +468,13 @@
|
||||
<string name="tailscale_key_expiry">密钥过期</string>
|
||||
<string name="tailscale_exit_node">出口节点</string>
|
||||
<string name="tailscale_active">活跃</string>
|
||||
<string name="tailscale_shared_in">共享至此</string>
|
||||
<string name="tailscale_expired">已过期</string>
|
||||
<string name="tailscale_expires_relative">%1$s过期</string>
|
||||
<string name="tailscale_key_expiry_disabled">密钥过期已禁用</string>
|
||||
<string name="tailscale_last_seen">最后在线</string>
|
||||
<string name="tailscale_available">可用</string>
|
||||
<string name="tailscale_yes">是</string>
|
||||
|
||||
<string name="stun_test">STUN 测试</string>
|
||||
<string name="stun_server">服务器</string>
|
||||
|
||||
@@ -471,6 +471,13 @@
|
||||
<string name="tailscale_key_expiry">金鑰到期</string>
|
||||
<string name="tailscale_exit_node">出口節點</string>
|
||||
<string name="tailscale_active">活躍</string>
|
||||
<string name="tailscale_shared_in">共享至此</string>
|
||||
<string name="tailscale_expired">已過期</string>
|
||||
<string name="tailscale_expires_relative">%1$s過期</string>
|
||||
<string name="tailscale_key_expiry_disabled">金鑰到期已停用</string>
|
||||
<string name="tailscale_last_seen">最後上線</string>
|
||||
<string name="tailscale_available">可用</string>
|
||||
<string name="tailscale_yes">是</string>
|
||||
|
||||
<string name="stun_test">STUN 測試</string>
|
||||
<string name="stun_server">伺服器</string>
|
||||
|
||||
@@ -481,6 +481,14 @@
|
||||
<string name="tailscale_ping_stop">Stop</string>
|
||||
<string name="tailscale_ping_direct">Direct connection</string>
|
||||
<string name="tailscale_ping_derp">DERP-relayed connection</string>
|
||||
<string name="tailscale_shared_in">Shared in</string>
|
||||
<string name="tailscale_expired">Expired</string>
|
||||
<string name="tailscale_expires_relative">Expires %1$s</string>
|
||||
<string name="tailscale_key_expiry_disabled">Key expiry disabled</string>
|
||||
<string name="tailscale_last_seen">Last Seen</string>
|
||||
<string name="tailscale_ssh" translatable="false">SSH</string>
|
||||
<string name="tailscale_available">Available</string>
|
||||
<string name="tailscale_yes">Yes</string>
|
||||
|
||||
<!-- STUN Test -->
|
||||
<string name="stun_test">STUN Test</string>
|
||||
|
||||
Reference in New Issue
Block a user