tailscale: Add exit node switcher & peer status

This commit is contained in:
世界
2026-07-06 21:15:24 +08:00
parent e2ea9fe08c
commit 06dd8ce686
10 changed files with 475 additions and 52 deletions
@@ -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.OutboundPickerScreen
import io.nekohasekai.sfa.compose.screen.tools.STUNTestScreen import io.nekohasekai.sfa.compose.screen.tools.STUNTestScreen
import io.nekohasekai.sfa.compose.screen.tools.TailscaleEndpointScreen 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.TailscalePeerScreen
import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel
import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen
@@ -278,6 +279,19 @@ fun SFANavHost(
TailscaleEndpointScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag) 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( composable(
route = "tools/tailscale/{endpointTag}/peer/{peerId}", route = "tools/tailscale/{endpointTag}/peer/{peerId}",
arguments = listOf( arguments = listOf(
@@ -2,6 +2,7 @@ package io.nekohasekai.sfa.compose.screen.tools
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.text.format.DateUtils
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -93,6 +94,7 @@ fun TailscaleEndpointScreen(
) { ) {
val hasNetwork = endpoint.networkName.isNotEmpty() val hasNetwork = endpoint.networkName.isNotEmpty()
val hasMagicDNS = endpoint.magicDNSSuffix.isNotEmpty() val hasMagicDNS = endpoint.magicDNSSuffix.isNotEmpty()
val hasExitNode = endpoint.backendState == "Running" && endpoint.hasExitNodeCandidates
val hasAuth = endpoint.authURL.isNotEmpty() val hasAuth = endpoint.authURL.isNotEmpty()
// Status section // Status section
@@ -106,7 +108,7 @@ fun TailscaleEndpointScreen(
), ),
) { ) {
Column { Column {
val stateIsLast = !hasNetwork && !hasMagicDNS && !hasAuth val stateIsLast = !hasNetwork && !hasMagicDNS && !hasExitNode && !hasAuth
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -142,7 +144,7 @@ fun TailscaleEndpointScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
if (hasNetwork) { if (hasNetwork) {
val networkIsLast = !hasMagicDNS && !hasAuth val networkIsLast = !hasMagicDNS && !hasExitNode && !hasAuth
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -166,7 +168,7 @@ fun TailscaleEndpointScreen(
) )
} }
if (hasMagicDNS) { if (hasMagicDNS) {
val magicDNSIsLast = !hasAuth val magicDNSIsLast = !hasExitNode && !hasAuth
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -189,6 +191,39 @@ fun TailscaleEndpointScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), 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) { if (hasAuth) {
ListItem( ListItem(
headlineContent = { headlineContent = {
@@ -323,34 +358,97 @@ private fun PeerItem(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ListItem( val badges = peerBadges(peer)
headlineContent = { 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( Text(
peer.hostName, peer.hostName,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
}, if (firstIP != null) {
supportingContent = if (peer.tailscaleIPs.isNotEmpty()) {
{
Text( Text(
peer.tailscaleIPs.first(), firstIP,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} else { if (badges.isNotEmpty()) {
null Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
}, for (badge in badges) {
leadingContent = { PeerBadgeView(badge)
Box( }
modifier = Modifier }
.size(8.dp) }
.clip(CircleShape) }
.background(if (peer.online) Color(0xFF4CAF50) else Color.Gray), }
) }
},
modifier = modifier.clickable(onClick = onClick), private data class PeerBadge(val text: String, val color: Color)
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@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),
) )
} }
@@ -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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -329,47 +328,89 @@ fun TailscalePeerScreen(
} }
// Details section // Details section
val showDetails = peer.keyExpiry > 0 || peer.os.isNotEmpty() || peer.exitNode Spacer(modifier = Modifier.height(16.dp))
if (showDetails) { SectionHeader(stringResource(R.string.tailscale_details))
Spacer(modifier = Modifier.height(16.dp)) Card(
SectionHeader(stringResource(R.string.tailscale_details)) modifier = Modifier
Card( .fillMaxWidth()
modifier = Modifier .padding(horizontal = 16.dp),
.fillMaxWidth() colors = CardDefaults.cardColors(
.padding(horizontal = 16.dp), containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
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 when {
Column( peer.expired -> {
modifier = Modifier.padding(16.dp), DetailRow(
verticalArrangement = Arrangement.spacedBy(12.dp), label = stringResource(R.string.tailscale_key_expiry),
) { value = stringResource(R.string.tailscale_expired),
if (peer.keyExpiry > 0) { valueColor = MaterialTheme.colorScheme.error,
)
}
peer.keyExpiry > 0 -> {
val expiryText = DateUtils.getRelativeTimeSpanString( val expiryText = DateUtils.getRelativeTimeSpanString(
peer.keyExpiry * 1000, peer.keyExpiry * 1000,
System.currentTimeMillis(), System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS, DateUtils.MINUTE_IN_MILLIS,
0,
).toString() ).toString()
DetailRow( DetailRow(
label = stringResource(R.string.tailscale_key_expiry), label = stringResource(R.string.tailscale_key_expiry),
value = expiryText, value = expiryText,
) )
} }
if (peer.os.isNotEmpty()) { else -> {
DetailRow( DetailRow(
label = stringResource(R.string.tailscale_os), label = stringResource(R.string.tailscale_key_expiry),
value = peer.os, value = stringResource(R.string.disabled),
) valueColor = MaterialTheme.colorScheme.onSurfaceVariant,
}
if (peer.exitNode) {
DetailRow(
label = stringResource(R.string.tailscale_exit_node),
value = stringResource(R.string.tailscale_active),
) )
} }
} }
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 @Composable
private fun DetailRow(label: String, value: String) { private fun DetailRow(label: String, value: String, valueColor: Color? = null) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -410,7 +451,7 @@ private fun DetailRow(label: String, value: String) {
Text( Text(
text = value, text = value,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurface, color = valueColor ?: MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.End, textAlign = TextAlign.End,
) )
} }
@@ -11,17 +11,22 @@ import kotlinx.coroutines.launch
data class TailscalePeerData( data class TailscalePeerData(
val id: String, val id: String,
val stableID: String,
val hostName: String, val hostName: String,
val dnsName: String, val dnsName: String,
val os: String, val os: String,
val tailscaleIPs: List<String>, val tailscaleIPs: List<String>,
val sshHostKeys: List<String>,
val online: Boolean, val online: Boolean,
val exitNode: Boolean, val exitNode: Boolean,
val exitNodeOption: Boolean, val exitNodeOption: Boolean,
val shareeNode: Boolean,
val expired: Boolean,
val active: Boolean, val active: Boolean,
val rxBytes: Long, val rxBytes: Long,
val txBytes: Long, val txBytes: Long,
val keyExpiry: Long, val keyExpiry: Long,
val lastSeen: Long,
) )
data class TailscaleUserGroupData( data class TailscaleUserGroupData(
@@ -39,8 +44,18 @@ data class TailscaleEndpointData(
val networkName: String, val networkName: String,
val magicDNSSuffix: String, val magicDNSSuffix: String,
val selfPeer: TailscalePeerData?, val selfPeer: TailscalePeerData?,
val exitNode: TailscalePeerData?,
val userGroups: List<TailscaleUserGroupData>, 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( data class TailscaleStatusState(
val endpoints: List<TailscaleEndpointData> = emptyList(), val endpoints: List<TailscaleEndpointData> = emptyList(),
@@ -92,6 +107,16 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
updateState { copy(endpoints = emptyList(), isSubscribed = false) } 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 endpoint(tag: String): TailscaleEndpointData? = currentState.endpoints.firstOrNull { it.endpointTag == tag }
fun peer(endpointTag: String, peerId: String): TailscalePeerData? { fun peer(endpointTag: String, peerId: String): TailscalePeerData? {
@@ -127,6 +152,7 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
userGroups.add(convertUserGroup(groupIterator.next())) userGroups.add(convertUserGroup(groupIterator.next()))
} }
val self = endpoint.getSelf() val self = endpoint.getSelf()
val exitNode = endpoint.exitNode
return TailscaleEndpointData( return TailscaleEndpointData(
endpointTag = endpoint.endpointTag, endpointTag = endpoint.endpointTag,
backendState = endpoint.backendState, backendState = endpoint.backendState,
@@ -134,6 +160,7 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
networkName = endpoint.networkName, networkName = endpoint.networkName,
magicDNSSuffix = endpoint.magicDNSSuffix, magicDNSSuffix = endpoint.magicDNSSuffix,
selfPeer = if (self != null) convertPeer(self) else null, selfPeer = if (self != null) convertPeer(self) else null,
exitNode = if (exitNode != null) convertPeer(exitNode) else null,
userGroups = userGroups, userGroups = userGroups,
) )
} }
@@ -161,20 +188,30 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
while (ipIterator.hasNext()) { while (ipIterator.hasNext()) {
ips.add(ipIterator.next()) ips.add(ipIterator.next())
} }
val sshKeys = mutableListOf<String>()
val keyIterator = peer.sshHostKeys()
while (keyIterator.hasNext()) {
sshKeys.add(keyIterator.next())
}
val dnsName = peer.getDNSName() val dnsName = peer.getDNSName()
return TailscalePeerData( return TailscalePeerData(
id = if (dnsName.isNotEmpty()) dnsName else peer.hostName, id = if (dnsName.isNotEmpty()) dnsName else peer.hostName,
stableID = peer.stableID,
hostName = peer.hostName, hostName = peer.hostName,
dnsName = dnsName, dnsName = dnsName,
os = peer.getOS(), os = peer.getOS(),
tailscaleIPs = ips, tailscaleIPs = ips,
sshHostKeys = sshKeys,
online = peer.online, online = peer.online,
exitNode = peer.exitNode, exitNode = peer.exitNode,
exitNodeOption = peer.exitNodeOption, exitNodeOption = peer.exitNodeOption,
shareeNode = peer.shareeNode,
expired = peer.expired,
active = peer.active, active = peer.active,
rxBytes = peer.rxBytes, rxBytes = peer.rxBytes,
txBytes = peer.txBytes, txBytes = peer.txBytes,
keyExpiry = peer.keyExpiry, keyExpiry = peer.keyExpiry,
lastSeen = peer.lastSeen,
) )
} }
} }
+7
View File
@@ -471,6 +471,13 @@
<string name="tailscale_key_expiry">انقضای کلید</string> <string name="tailscale_key_expiry">انقضای کلید</string>
<string name="tailscale_exit_node">گره خروجی</string> <string name="tailscale_exit_node">گره خروجی</string>
<string name="tailscale_active">فعال</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_test">تست STUN</string>
<string name="stun_server">سرور</string> <string name="stun_server">سرور</string>
@@ -477,6 +477,13 @@
<string name="tailscale_key_expiry">Срок действия ключа</string> <string name="tailscale_key_expiry">Срок действия ключа</string>
<string name="tailscale_exit_node">Выходной узел</string> <string name="tailscale_exit_node">Выходной узел</string>
<string name="tailscale_active">Активен</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_test">STUN-тест</string>
<string name="stun_server">Сервер</string> <string name="stun_server">Сервер</string>
@@ -468,6 +468,13 @@
<string name="tailscale_key_expiry">密钥过期</string> <string name="tailscale_key_expiry">密钥过期</string>
<string name="tailscale_exit_node">出口节点</string> <string name="tailscale_exit_node">出口节点</string>
<string name="tailscale_active">活跃</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_test">STUN 测试</string>
<string name="stun_server">服务器</string> <string name="stun_server">服务器</string>
@@ -471,6 +471,13 @@
<string name="tailscale_key_expiry">金鑰到期</string> <string name="tailscale_key_expiry">金鑰到期</string>
<string name="tailscale_exit_node">出口節點</string> <string name="tailscale_exit_node">出口節點</string>
<string name="tailscale_active">活躍</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_test">STUN 測試</string>
<string name="stun_server">伺服器</string> <string name="stun_server">伺服器</string>
+8
View File
@@ -481,6 +481,14 @@
<string name="tailscale_ping_stop">Stop</string> <string name="tailscale_ping_stop">Stop</string>
<string name="tailscale_ping_direct">Direct connection</string> <string name="tailscale_ping_direct">Direct connection</string>
<string name="tailscale_ping_derp">DERP-relayed 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 --> <!-- STUN Test -->
<string name="stun_test">STUN Test</string> <string name="stun_test">STUN Test</string>