From 4450aacdf90f42304d48da6153e9181949b5a230 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 16 Mar 2026 11:01:52 +0200 Subject: [PATCH] feat(media): Blossom server management settings with health checks Phase 8: MediaServerSettings UI for add/remove/reorder Blossom servers, ServerHealthCheck with 5s timeout HEAD requests, status indicators. Co-Authored-By: Claude Opus 4.6 --- .../service/media/ServerHealthCheck.kt | 64 +++++ .../ui/settings/MediaServerSettings.kt | 261 ++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt new file mode 100644 index 000000000..bfa1b3295 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.media + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +object ServerHealthCheck { + private val httpClient = + OkHttpClient + .Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + + enum class ServerStatus { + ONLINE, + OFFLINE, + UNKNOWN, + } + + /** + * Check if a Blossom server is reachable via HEAD request. + */ + suspend fun check(serverUrl: String): ServerStatus = + withContext(Dispatchers.IO) { + try { + val url = serverUrl.removeSuffix("/") + val request = + Request + .Builder() + .url(url) + .head() + .build() + val response = httpClient.newCall(request).execute() + response.use { + if (it.isSuccessful || it.code == 405) ServerStatus.ONLINE else ServerStatus.OFFLINE + } + } catch (_: Exception) { + ServerStatus.OFFLINE + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt new file mode 100644 index 000000000..8dc85c540 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +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.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck +import kotlinx.coroutines.launch + +@Composable +fun MediaServerSettings( + initialServers: List = emptyList(), + onServersChanged: (List) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val servers = remember { mutableStateListOf().apply { addAll(initialServers) } } + val serverStatuses = remember { mutableStateMapOf() } + var newServerUrl by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + var isChecking by remember { mutableStateOf(false) } + + // Check health on first load + LaunchedEffect(servers.toList()) { + for (server in servers) { + if (server !in serverStatuses) { + val status = ServerHealthCheck.check(server) + serverStatuses[server] = status + } + } + } + + Column(modifier = modifier.fillMaxWidth().padding(16.dp)) { + Text( + "Media Servers (Blossom)", + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "Configure Blossom servers for media uploads. First server is the default.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + // Server list + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f, fill = false), + ) { + items(servers) { server -> + ServerRow( + server = server, + status = serverStatuses[server] ?: ServerHealthCheck.ServerStatus.UNKNOWN, + isDefault = servers.indexOf(server) == 0, + onRemove = { + servers.remove(server) + serverStatuses.remove(server) + onServersChanged(servers.toList()) + }, + onRefresh = { + scope.launch { + serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN + val status = ServerHealthCheck.check(server) + serverStatuses[server] = status + } + }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + // Add server + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = newServerUrl, + onValueChange = { newServerUrl = it }, + label = { Text("Server URL") }, + placeholder = { Text("https://blossom.example.com") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + + Spacer(Modifier.width(8.dp)) + + Button( + onClick = { + val url = newServerUrl.trim().removeSuffix("/") + if (url.isNotBlank() && url !in servers) { + servers.add(url) + newServerUrl = "" + onServersChanged(servers.toList()) + scope.launch { + val status = ServerHealthCheck.check(url) + serverStatuses[url] = status + } + } + }, + enabled = newServerUrl.isNotBlank(), + ) { + Icon(Icons.Default.Add, contentDescription = "Add") + Spacer(Modifier.width(4.dp)) + Text("Add") + } + } + + Spacer(Modifier.height(8.dp)) + + // Refresh all + Button( + onClick = { + scope.launch { + isChecking = true + for (server in servers) { + serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN + val status = ServerHealthCheck.check(server) + serverStatuses[server] = status + } + isChecking = false + } + }, + enabled = !isChecking, + ) { + if (isChecking) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + } else { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + Spacer(Modifier.width(4.dp)) + Text("Check All") + } + } +} + +@Composable +private fun ServerRow( + server: String, + status: ServerHealthCheck.ServerStatus, + isDefault: Boolean, + onRemove: () -> Unit, + onRefresh: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Status indicator + Surface( + modifier = Modifier.size(12.dp), + shape = CircleShape, + color = + when (status) { + ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50) + ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336) + ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E) + }, + ) {} + + Spacer(Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + server, + style = MaterialTheme.typography.bodyMedium, + ) + if (isDefault) { + Text( + "Default server", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + IconButton(onClick = onRefresh) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + modifier = Modifier.size(18.dp), + ) + } + + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +}