Skip to content

Commit 56d7be3

Browse files
authored
ui: display error dialog when saving subnet routes fails (#604)
Fixes tailscale/corp#26175 When setting subnet routing settings, for a variety of reasons the Tailscale backend may reject an entered value with a 400 error. Here we handle such errors in a user-facing fashion: - We display an ErrorDialog with title 'Failed to save' and whatever error message the backend request returned. To do so, we introduce a new initializer for ErrorDialog that accepts a runtime-generated String instead of a fixed string resource. - We ask the backend to provide an updated value of AdvertiseRoutes whenever the error dialog is dismissed by the user, and set it as the UI state, to ensure consistency between UI and backend upon a failed save. Signed-off-by: Andrea Gottardo <[email protected]>
1 parent 0ed18a2 commit 56d7be3

File tree

4 files changed

+61
-11
lines changed

4 files changed

+61
-11
lines changed

android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,12 @@ enum class ErrorDialogType {
5353

5454
@Composable
5555
fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
56-
ErrorDialog(
57-
title = type.title, message = type.message, buttonText = type.buttonText, onDismiss = action)
56+
ErrorDialog(
57+
title = type.title,
58+
message = stringResource(id = type.message),
59+
buttonText = type.buttonText,
60+
onDismiss = action
61+
)
5862
}
5963

6064
@Composable
@@ -64,15 +68,30 @@ fun ErrorDialog(
6468
@StringRes buttonText: Int = R.string.ok,
6569
onDismiss: () -> Unit = {}
6670
) {
67-
AppTheme {
68-
AlertDialog(
69-
onDismissRequest = onDismiss,
70-
title = { Text(text = stringResource(id = title)) },
71-
text = { Text(text = stringResource(id = message)) },
72-
confirmButton = {
73-
PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) }
74-
})
75-
}
71+
ErrorDialog(
72+
title = title,
73+
message = stringResource(id = message),
74+
buttonText = buttonText,
75+
onDismiss = onDismiss
76+
)
77+
}
78+
79+
@Composable
80+
fun ErrorDialog(
81+
@StringRes title: Int = R.string.error,
82+
message: String,
83+
@StringRes buttonText: Int = R.string.ok,
84+
onDismiss: () -> Unit = {}
85+
) {
86+
AppTheme {
87+
AlertDialog(
88+
onDismissRequest = onDismiss,
89+
title = { Text(text = stringResource(id = title)) },
90+
text = { Text(text = message) },
91+
confirmButton = {
92+
PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) }
93+
})
94+
}
7695
}
7796

7897
@Preview

android/src/main/java/com/tailscale/ipn/ui/view/SubnetRoutingView.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ fun SubnetRoutingView(backToSettings: BackNavigation, model: SubnetRoutingViewMo
4040
val uriHandler = LocalUriHandler.current
4141
val isPresentingDialog by model.isPresentingDialog.collectAsState()
4242
val useSubnets by model.routeAll.collectAsState()
43+
val currentError by model.currentError.collectAsState()
4344

4445
Scaffold(topBar = {
4546
Header(R.string.subnet_routes, onBack = backToSettings, actions = {
@@ -56,6 +57,13 @@ fun SubnetRoutingView(backToSettings: BackNavigation, model: SubnetRoutingViewMo
5657
}) { innerPadding ->
5758
LoadingIndicator.Wrap {
5859
LazyColumn(modifier = Modifier.padding(innerPadding)) {
60+
currentError?.let {
61+
item("error") {
62+
ErrorDialog(title = R.string.failed_to_save, message = it, onDismiss = {
63+
model.onErrorDismissed()
64+
})
65+
}
66+
}
5967
item("subnetsToggle") {
6068
Setting.Switch(R.string.use_tailscale_subnets, isOn = useSubnets, onToggle = {
6169
LoadingIndicator.start()

android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ class SubnetRoutingViewModel : ViewModel() {
6363
*/
6464
val isTextFieldValueValid: StateFlow<Boolean> = MutableStateFlow(true)
6565

66+
/**
67+
* If an error occurred while saving the ipn.Prefs to the backend this value is
68+
* non-null. Subsequent successful attempts to save will clear it.
69+
*/
70+
val currentError: MutableStateFlow<String?> = MutableStateFlow(null)
71+
6672
init {
6773
viewModelScope.launch {
6874
// Any time the value entered by the user in the add/edit dialog changes, we determine
@@ -113,12 +119,14 @@ class SubnetRoutingViewModel : ViewModel() {
113119
Client(viewModelScope).editPrefs(prefsOut, responseHandler = { result ->
114120
if (result.isFailure) {
115121
Log.e(TAG, "Error saving RouteAll: ${result.exceptionOrNull()}")
122+
currentError.set(result.exceptionOrNull()?.localizedMessage)
116123
return@editPrefs
117124
} else {
118125
Log.d(
119126
TAG,
120127
"RouteAll set in backend. New value: ${result.getOrNull()?.RouteAll}"
121128
)
129+
currentError.set(null)
122130
}
123131
})
124132
}
@@ -221,16 +229,30 @@ class SubnetRoutingViewModel : ViewModel() {
221229
Client(viewModelScope).editPrefs(prefsOut, responseHandler = { result ->
222230
if (result.isFailure) {
223231
Log.e(TAG, "Error saving AdvertiseRoutes: ${result.exceptionOrNull()}")
232+
currentError.set(result.exceptionOrNull()?.localizedMessage)
224233
return@editPrefs
225234
} else {
226235
Log.d(
227236
TAG,
228237
"AdvertiseRoutes set in backend. New value: ${result.getOrNull()?.AdvertiseRoutes}"
229238
)
239+
currentError.set(null)
230240
}
231241
})
232242
}
233243

244+
/**
245+
* Clears the current error message and reloads the routes currently saved in the backend
246+
* to the UI. We call this when dismissing an error upon saving the routes.
247+
*/
248+
fun onErrorDismissed() {
249+
currentError.set(null)
250+
Client(viewModelScope).prefs { response ->
251+
Log.d(TAG, "Reloading routes from backend due to failed save: $response")
252+
this.advertisedRoutes.set(response.getOrNull()?.AdvertiseRoutes ?: emptyList())
253+
}
254+
}
255+
234256
companion object RouteValidation {
235257
/**
236258
* Returns true if the given String is a valid IPv4 or IPv6 CIDR range, false otherwise.

android/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,5 +321,6 @@
321321
<string name="subnet_routing">Subnet routing</string>
322322
<string name="specifies_a_device_name_to_be_used_instead_of_the_automatic_default">Specifies a device name to be used instead of the automatic default.</string>
323323
<string name="hostname">Hostname</string>
324+
<string name="failed_to_save">Failed to save</string>
324325

325326
</resources>

0 commit comments

Comments
 (0)