A screen for the machines, and failures a phone can act on
The other half of making setups editable: add, rename, rediscover and remove, with a Test that tries a machine before anything is saved. The screen cannot name a program, which is the point rather than an omission -- providers are what the server found when it asked, so this app has no way to introduce something to run. The dialog says so, because "what it can run is discovered, not typed" is the answer to the question a person will otherwise ask when they look for a command field. Two things running it changed. The card showed "this machine / this machine", because the seeded setup is *called* that and my fallback line for a local setup said the same -- the line now says something the name cannot also be. And the header row absorbed a fifth action without complaint, which is the earlier title-and-actions split paying off exactly as its comment predicted. **Host key verification is the failure that would have made this look broken.** Every machine fails it the first time, because its key is not in known_hosts yet, and ssh's own words -- "Host key verification failed." -- are written for somebody at a terminal on the backend, which is exactly who is not reading a phone. It now says what to do: ssh to it once from the backend and try again. Permission denied gets the same treatment. Deliberately *not* fixed by relaxing StrictHostKeyChecking. Accepting a new key is a decision somebody should make with the key in front of them, not something this app does quietly on their behalf while adding a machine. Verified on the emulator against a running server: the seeded setup renders with what was discovered on it, the add dialog explains itself, and Test against an untrusted machine produces the full explanation rather than ssh's four words. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
19e3531c5d
commit
3c144f8070
5 files changed
+506
-19
No files matched your search
@@ -140,28 +140,110 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||
// would offer pairs that cannot work.
|
||||
data class Provider(val name: String, val kind: String, val models: List<String>)
|
||||
|
||||
/** A machine, and what it can run. [address] is absent for the backend itself. */
|
||||
data class Setup(val name: String, val address: String?, val providers: List<Provider>)
|
||||
/**
|
||||
* A machine, and what it can run. [address] is absent for the backend itself.
|
||||
*
|
||||
* [id] is stable and [name] is not: renaming a machine keeps its sessions, so everything that
|
||||
* refers to a setup uses the id and everything a person reads uses the name.
|
||||
*/
|
||||
data class Setup(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val address: String?,
|
||||
val providers: List<Provider>,
|
||||
)
|
||||
|
||||
private fun parseProvider(provider: JSONObject) =
|
||||
Provider(
|
||||
name = provider.getString("name"),
|
||||
kind = provider.getString("kind"),
|
||||
// Omitted entirely when the provider offers none.
|
||||
models = provider.optJSONArray("models")?.strings().orEmpty(),
|
||||
)
|
||||
|
||||
private fun parseSetup(setup: JSONObject) =
|
||||
Setup(
|
||||
id = setup.getString("id"),
|
||||
name = setup.getString("name"),
|
||||
address = setup.optString("address").ifEmpty { null },
|
||||
providers = setup.getJSONArray("providers").mapObjects(::parseProvider),
|
||||
)
|
||||
|
||||
fun fetchSetups(settings: ServerSettings): List<Setup> =
|
||||
requestFromServer(settings, "/setups") { connection ->
|
||||
connection.jsonObjects { setup ->
|
||||
Setup(
|
||||
name = setup.getString("name"),
|
||||
address = setup.optString("address").ifEmpty { null },
|
||||
providers =
|
||||
setup.getJSONArray("providers").mapObjects { provider ->
|
||||
Provider(
|
||||
name = provider.getString("name"),
|
||||
kind = provider.getString("kind"),
|
||||
// Omitted entirely when the provider offers none.
|
||||
models = provider.optJSONArray("models")?.strings().orEmpty(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) }
|
||||
|
||||
/**
|
||||
* How to reach a machine. Deliberately carries no command: the server discovers what a machine can
|
||||
* run by asking it, so this app has no way to introduce something to run.
|
||||
*
|
||||
* [identityFile] is a path on the *backend*, not a key -- private keys do not travel.
|
||||
*/
|
||||
data class SshDetails(
|
||||
val address: String,
|
||||
val port: Int? = null,
|
||||
val identityFile: String? = null,
|
||||
)
|
||||
|
||||
private fun SshDetails.toJson() =
|
||||
JSONObject().put("address", address).apply {
|
||||
if (port != null) put("port", port)
|
||||
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
||||
}
|
||||
|
||||
/** What a machine turns out to have, without saving anything. */
|
||||
fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/probe",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().apply { if (ssh != null) put("ssh", ssh.toJson()) }.toString(),
|
||||
readTimeoutMs = 40000,
|
||||
) {
|
||||
it.jsonObjects(::parseProvider)
|
||||
}
|
||||
|
||||
fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups",
|
||||
method = "POST",
|
||||
jsonBody =
|
||||
JSONObject()
|
||||
.put("name", name)
|
||||
.apply { if (ssh != null) put("ssh", ssh.toJson()) }
|
||||
.toString(),
|
||||
readTimeoutMs = 40000,
|
||||
) {
|
||||
parseSetup(it.jsonObject())
|
||||
}
|
||||
|
||||
/** Renames a machine, and optionally asks it again what it has. */
|
||||
fun updateSetup(
|
||||
settings: ServerSettings,
|
||||
id: String,
|
||||
name: String? = null,
|
||||
rediscover: Boolean = false,
|
||||
): Setup =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${id.urlEncoded()}",
|
||||
method = "PUT",
|
||||
jsonBody =
|
||||
JSONObject()
|
||||
.apply {
|
||||
if (name != null) put("name", name)
|
||||
if (rediscover) put("rediscover", true)
|
||||
}
|
||||
.toString(),
|
||||
readTimeoutMs = 40000,
|
||||
) {
|
||||
parseSetup(it.jsonObject())
|
||||
}
|
||||
|
||||
fun deleteSetup(settings: ServerSettings, id: String) {
|
||||
requestFromServer(settings, "/setups/${id.urlEncoded()}", method = "DELETE") {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a session and returns it as the list would show it. [setup] names the machine and
|
||||
* [provider] one of the things that machine offers.
|
||||
|
||||
@@ -24,6 +24,8 @@ private sealed class Screen {
|
||||
|
||||
data object Models : Screen()
|
||||
|
||||
data object Setups : Screen()
|
||||
|
||||
data object Settings : Screen()
|
||||
}
|
||||
|
||||
@@ -76,6 +78,7 @@ fun AppRoot(settingsVersion: Int) {
|
||||
onSpawn = { screen = Screen.Spawn },
|
||||
onUsage = { screen = Screen.Usage },
|
||||
onModels = { screen = Screen.Models },
|
||||
onSetups = { screen = Screen.Setups },
|
||||
onSettings = { screen = Screen.Settings },
|
||||
)
|
||||
is Screen.Session ->
|
||||
@@ -95,6 +98,7 @@ fun AppRoot(settingsVersion: Int) {
|
||||
)
|
||||
is Screen.Usage -> UsageScreen(settings = current, onBack = goToList)
|
||||
is Screen.Models -> ModelsScreen(settings = current, onBack = goToList)
|
||||
is Screen.Setups -> SetupsScreen(settings = current, onBack = goToList)
|
||||
is Screen.Settings ->
|
||||
SettingsScreen(
|
||||
existing = current,
|
||||
|
||||
@@ -52,6 +52,7 @@ fun SessionListScreen(
|
||||
onSpawn: () -> Unit,
|
||||
onUsage: () -> Unit,
|
||||
onModels: () -> Unit,
|
||||
onSetups: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -100,6 +101,7 @@ fun SessionListScreen(
|
||||
) {
|
||||
TextButton(onClick = onUsage) { Text("Usage") }
|
||||
TextButton(onClick = onModels) { Text("Models") }
|
||||
TextButton(onClick = onSetups) { Text("Setups") }
|
||||
TextButton(onClick = onSettings) { Text("Settings") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = { refresh() }) { Text("Refresh") }
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The machines this backend can run things on.
|
||||
*
|
||||
* Note what this screen cannot do: name a program. Providers are what the server found when it
|
||||
* asked the machine, so adding one is "here is how to reach it" and never "here is what to run" --
|
||||
* which is what keeps the enrolled token from being able to introduce commands.
|
||||
*/
|
||||
@Composable
|
||||
fun SetupsScreen(settings: ServerSettings, onBack: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var renaming by remember { mutableStateOf<Setup?>(null) }
|
||||
var confirmingDelete by remember { mutableStateOf<Setup?>(null) }
|
||||
var busy by remember { mutableStateOf<String?>(null) }
|
||||
var actionError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
suspend fun reload() {
|
||||
state =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) }
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Text("Setups", style = MaterialTheme.typography.headlineSmall)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
TextButton(onClick = { adding = true }) { Text("Add machine") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = onBack) { Text("Back") }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
actionError?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
busy?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
when (val current = state) {
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
||||
is LoadState.Loaded ->
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(current.value, key = { it.id }) { setup ->
|
||||
SetupCard(
|
||||
setup = setup,
|
||||
onRename = { renaming = setup },
|
||||
onRediscover = {
|
||||
scope.launch {
|
||||
busy = "Asking ${setup.name} what it has…"
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
updateSetup(
|
||||
settings,
|
||||
setup.id,
|
||||
rediscover = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
.exceptionOrNull()
|
||||
?.message
|
||||
busy = null
|
||||
reload()
|
||||
}
|
||||
},
|
||||
onDelete = { confirmingDelete = setup },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (adding) {
|
||||
AddSetupDialog(
|
||||
onDismiss = { adding = false },
|
||||
onAdd = { name, ssh ->
|
||||
adding = false
|
||||
scope.launch {
|
||||
busy = "Asking $name what it has…"
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
|
||||
}
|
||||
.exceptionOrNull()
|
||||
?.message
|
||||
busy = null
|
||||
reload()
|
||||
}
|
||||
},
|
||||
onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
|
||||
)
|
||||
}
|
||||
|
||||
renaming?.let { setup ->
|
||||
RenameDialog(
|
||||
setup = setup,
|
||||
onDismiss = { renaming = null },
|
||||
onRename = { name ->
|
||||
renaming = null
|
||||
scope.launch {
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
updateSetup(settings, setup.id, name = name)
|
||||
}
|
||||
}
|
||||
.exceptionOrNull()
|
||||
?.message
|
||||
reload()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
confirmingDelete?.let { setup ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmingDelete = null },
|
||||
title = { Text("Remove \"${setup.name}\"?") },
|
||||
text = {
|
||||
Text(
|
||||
"The machine is left alone -- this only stops this app offering it. " +
|
||||
"Sessions still running on it must be deleted first."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmingDelete = null
|
||||
scope.launch {
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) }
|
||||
}
|
||||
.exceptionOrNull()
|
||||
?.message
|
||||
reload()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Remove")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupCard(
|
||||
setup: Setup,
|
||||
onRename: () -> Unit,
|
||||
onRediscover: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(setup.name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
// Not "this machine": the seeded setup is *called* that,
|
||||
// and the card read "this machine / this machine". The
|
||||
// line has to say something the name cannot also be.
|
||||
setup.address ?: "runs where the backend does",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
if (setup.providers.isEmpty()) {
|
||||
"Nothing found on it. Install something and rediscover."
|
||||
} else {
|
||||
setup.providers.joinToString(" · ") { it.name }
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onRename) { Text("Rename") }
|
||||
TextButton(onClick = onRediscover) { Text("Rediscover") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = onDelete) { Text("Remove") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddSetupDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (String, SshDetails?) -> Unit,
|
||||
onTest: suspend (SshDetails?) -> List<Provider>,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by remember { mutableStateOf("") }
|
||||
var address by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("") }
|
||||
var identity by remember { mutableStateOf("") }
|
||||
var tested by remember { mutableStateOf<String?>(null) }
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
|
||||
fun details(): SshDetails? =
|
||||
address
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let {
|
||||
SshDetails(
|
||||
address = it,
|
||||
port = port.trim().toIntOrNull(),
|
||||
identityFile = identity.trim().ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add a machine") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"Leave the address blank for the machine the backend runs on. " +
|
||||
"What it can run is discovered, not typed.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = address,
|
||||
onValueChange = { address = it },
|
||||
label = { Text("user@host (blank = this machine)") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { port = it },
|
||||
label = { Text("Port (blank = 22)") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = identity,
|
||||
onValueChange = { identity = it },
|
||||
label = { Text("Key path on the backend") },
|
||||
singleLine = true,
|
||||
)
|
||||
tested?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = name.isNotBlank(), onClick = { onAdd(name.trim(), details()) }) {
|
||||
Text("Add")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
// Tried before saving, so a wrong address or an
|
||||
// unauthorised key is caught while this form is still on
|
||||
// screen rather than at the first spawn.
|
||||
TextButton(
|
||||
enabled = !testing,
|
||||
onClick = {
|
||||
testing = true
|
||||
tested = "Asking…"
|
||||
scope.launch {
|
||||
tested =
|
||||
runCatching { onTest(details()) }
|
||||
.fold(
|
||||
onSuccess = { found ->
|
||||
if (found.isEmpty()) {
|
||||
"Reached it, but found nothing it can run."
|
||||
} else {
|
||||
"Found ${found.joinToString(", ") { it.name }}"
|
||||
}
|
||||
},
|
||||
onFailure = { it.message ?: "Couldn't reach it" },
|
||||
)
|
||||
testing = false
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Test")
|
||||
}
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
||||
var name by remember { mutableStateOf(setup.name) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Sessions already running on it keep working -- they refer to the machine, " +
|
||||
"not to what it is called.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = name.isNotBlank(), onClick = { onRename(name.trim()) }) {
|
||||
Text("Rename")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user