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.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(
|
||||||
|
|||||||
+120
-22
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-1
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user