fix: install/uninstall UI state, progress bar, auto-Tor hidden services
- Install progress bar replaces action buttons (no overlay) - Hide status badge during install/uninstall - Uninstall keeps progress state until container disappears from WebSocket - Uninstall RPC timeout increased to 660s (Bitcoin UTXO flush) - Installing apps appear in My Apps immediately as placeholders - Auto-configure Tor hidden service for every app on install - Widen Tor module visibility for install hooks - Only clear stale install entries on error status Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c117f5718
commit
8e094c7ce9
@ -801,6 +801,48 @@ autopilot.active=false\n",
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-configure Tor hidden service for protocol services (LND, ElectrumX, Bitcoin)
|
||||||
|
{
|
||||||
|
use crate::api::rpc::tor::{
|
||||||
|
known_service_port, is_protocol_service, load_services_config,
|
||||||
|
save_services_config, regenerate_torrc, restart_tor, wait_for_hostname,
|
||||||
|
sync_single_hostname, TorServiceEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
let tor_port = known_service_port(package_id);
|
||||||
|
if tor_port > 0 {
|
||||||
|
let config_dir = self.config.data_dir.join("tor-config");
|
||||||
|
let mut config = load_services_config(&config_dir).await;
|
||||||
|
let already_exists = config.services.iter().any(|s| s.name == package_id);
|
||||||
|
|
||||||
|
if !already_exists {
|
||||||
|
let is_proto = is_protocol_service(package_id);
|
||||||
|
config.services.push(TorServiceEntry {
|
||||||
|
name: package_id.to_string(),
|
||||||
|
local_port: tor_port,
|
||||||
|
remote_port: None,
|
||||||
|
unauthenticated: is_proto,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
if let Err(e) = save_services_config(&config_dir, &config).await {
|
||||||
|
tracing::warn!("Failed to save Tor config for {}: {}", package_id, e);
|
||||||
|
} else if let Err(e) = regenerate_torrc(&config).await {
|
||||||
|
tracing::warn!("Failed to regenerate torrc for {}: {}", package_id, e);
|
||||||
|
} else if let Err(e) = restart_tor().await {
|
||||||
|
tracing::warn!("Failed to restart Tor for {}: {}", package_id, e);
|
||||||
|
} else {
|
||||||
|
let onion = wait_for_hostname(package_id, 30).await;
|
||||||
|
if let Some(ref addr) = onion {
|
||||||
|
sync_single_hostname(package_id, addr).await;
|
||||||
|
info!("Tor hidden service created for {} → {}", package_id, addr);
|
||||||
|
} else {
|
||||||
|
info!("Tor hidden service created for {} (hostname pending)", package_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if package_id == "nextcloud" {
|
if package_id == "nextcloud" {
|
||||||
let host_ip = &self.config.host_ip;
|
let host_ip = &self.config.host_ip;
|
||||||
// Wait for Nextcloud to finish first-run initialization
|
// Wait for Nextcloud to finish first-run initialization
|
||||||
|
|||||||
@ -23,12 +23,12 @@ pub(super) struct TorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
pub(super) struct ServicesConfig {
|
pub(in crate::api::rpc) struct ServicesConfig {
|
||||||
pub services: Vec<TorServiceEntry>,
|
pub services: Vec<TorServiceEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub(super) struct TorServiceEntry {
|
pub(in crate::api::rpc) struct TorServiceEntry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub local_port: u16,
|
pub local_port: u16,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -106,7 +106,7 @@ pub(super) async fn rename_hidden_service_dir(name: &str, timestamp: u64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn restart_tor() -> Result<()> {
|
pub(in crate::api::rpc) async fn restart_tor() -> Result<()> {
|
||||||
dispatch_tor_action(serde_json::json!({
|
dispatch_tor_action(serde_json::json!({
|
||||||
"action": "write-torrc-and-restart",
|
"action": "write-torrc-and-restart",
|
||||||
})).await
|
})).await
|
||||||
@ -131,7 +131,7 @@ pub(super) fn detect_hidden_service_base() -> String {
|
|||||||
"/var/lib/tor".to_string()
|
"/var/lib/tor".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
|
pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
|
||||||
let base = detect_hidden_service_base();
|
let base = detect_hidden_service_base();
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
@ -175,7 +175,7 @@ pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
|
|||||||
|
|
||||||
// ─── Hostname Sync ───────────────────────────────────────────────
|
// ─── Hostname Sync ───────────────────────────────────────────────
|
||||||
|
|
||||||
pub(super) async fn sync_single_hostname(name: &str, address: &str) {
|
pub(in crate::api::rpc) async fn sync_single_hostname(name: &str, address: &str) {
|
||||||
let hostnames_dir = Path::new("/var/lib/archipelago/tor-hostnames");
|
let hostnames_dir = Path::new("/var/lib/archipelago/tor-hostnames");
|
||||||
if let Err(e) = tokio::fs::create_dir_all(hostnames_dir).await {
|
if let Err(e) = tokio::fs::create_dir_all(hostnames_dir).await {
|
||||||
warn!("Failed to create tor-hostnames dir: {}", e);
|
warn!("Failed to create tor-hostnames dir: {}", e);
|
||||||
@ -306,7 +306,7 @@ fn is_valid_v3_onion(s: &str) -> bool {
|
|||||||
|
|
||||||
// ─── Known Ports ─────────────────────────────────────────────────
|
// ─── Known Ports ─────────────────────────────────────────────────
|
||||||
|
|
||||||
pub(super) fn known_service_port(name: &str) -> u16 {
|
pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
|
||||||
match name {
|
match name {
|
||||||
"archipelago" => 80,
|
"archipelago" => 80,
|
||||||
"bitcoin" | "bitcoin-knots" => 8333,
|
"bitcoin" | "bitcoin-knots" => 8333,
|
||||||
@ -331,7 +331,7 @@ pub(super) fn known_service_port(name: &str) -> u16 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn is_protocol_service(name: &str) -> bool {
|
pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
|
||||||
matches!(name, "bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd")
|
matches!(name, "bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -341,7 +341,7 @@ fn tor_data_dir() -> String {
|
|||||||
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string())
|
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn load_services_config(config_dir: &std::path::Path) -> ServicesConfig {
|
pub(in crate::api::rpc) async fn load_services_config(config_dir: &std::path::Path) -> ServicesConfig {
|
||||||
let path = config_dir.join(SERVICES_CONFIG);
|
let path = config_dir.join(SERVICES_CONFIG);
|
||||||
match tokio::fs::read_to_string(&path).await {
|
match tokio::fs::read_to_string(&path).await {
|
||||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||||
@ -349,7 +349,7 @@ pub(super) async fn load_services_config(config_dir: &std::path::Path) -> Servic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn save_services_config(config_dir: &std::path::Path, config: &ServicesConfig) -> Result<()> {
|
pub(in crate::api::rpc) async fn save_services_config(config_dir: &std::path::Path, config: &ServicesConfig) -> Result<()> {
|
||||||
tokio::fs::create_dir_all(config_dir).await.context("Failed to create tor config dir")?;
|
tokio::fs::create_dir_all(config_dir).await.context("Failed to create tor config dir")?;
|
||||||
let path = config_dir.join(SERVICES_CONFIG);
|
let path = config_dir.join(SERVICES_CONFIG);
|
||||||
let content = serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
|
let content = serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
|
||||||
@ -418,7 +418,7 @@ pub(super) async fn notify_federation_peers_address_change(
|
|||||||
|
|
||||||
// ─── Hostname Waiting ────────────────────────────────────────────
|
// ─── Hostname Waiting ────────────────────────────────────────────
|
||||||
|
|
||||||
pub(super) async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option<String> {
|
pub(in crate::api::rpc) async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option<String> {
|
||||||
for _ in 0..max_secs {
|
for _ in 0..max_secs {
|
||||||
if let Some(addr) = read_onion_address(service_name).await {
|
if let Some(addr) = read_onion_address(service_name).await {
|
||||||
return Some(addr);
|
return Some(addr);
|
||||||
|
|||||||
@ -517,7 +517,7 @@ class RPCClient {
|
|||||||
return this.call({
|
return this.call({
|
||||||
method: 'package.uninstall',
|
method: 'package.uninstall',
|
||||||
params: { id },
|
params: { id },
|
||||||
timeout: 120000,
|
timeout: 660000, // Bitcoin Knots needs up to 600s for UTXO flush
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -57,16 +57,21 @@ export const useServerStore = defineStore('server', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clear installingApps entries for apps that vanished from backend data
|
// Clear installingApps entries for apps that vanished from backend data
|
||||||
// (container was removed, install failed and was cleaned up, etc.)
|
// Only clean up entries that have errored — active installs may take minutes to pull images
|
||||||
for (const [appId] of installingApps.value) {
|
for (const [appId] of installingApps.value) {
|
||||||
if (packages && !(appId in packages)) {
|
if (packages && !(appId in packages)) {
|
||||||
const entry = installingApps.value.get(appId)
|
const entry = installingApps.value.get(appId)
|
||||||
if (entry && entry.attempt > 30) {
|
if (entry && entry.status === 'error') {
|
||||||
// App has been "installing" for 30+ seconds but backend doesn't know about it — failed
|
|
||||||
installingApps.value.delete(appId)
|
installingApps.value.delete(appId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Clear uninstallingApps when the container disappears from backend data
|
||||||
|
for (const appId of uninstallingApps.value) {
|
||||||
|
if (packages && !(appId in packages)) {
|
||||||
|
uninstallingApps.value.delete(appId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
|
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
|
||||||
|
|||||||
@ -207,7 +207,7 @@ const packages = computed(() => {
|
|||||||
id: appId,
|
id: appId,
|
||||||
title: progress.title,
|
title: progress.title,
|
||||||
version: '',
|
version: '',
|
||||||
description: { short: progress.message, long: '' },
|
description: { short: '', long: '' },
|
||||||
'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '',
|
'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '',
|
||||||
'support-site': '', 'marketing-site': '', 'donation-url': null,
|
'support-site': '', 'marketing-site': '', 'donation-url': null,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -12,19 +12,7 @@
|
|||||||
>
|
>
|
||||||
<!-- Installing indicator — no overlay, just replaces action buttons at bottom -->
|
<!-- Installing indicator — no overlay, just replaces action buttons at bottom -->
|
||||||
|
|
||||||
<!-- Uninstalling overlay -->
|
<!-- Uninstalling — handled in button area below, no overlay -->
|
||||||
<div
|
|
||||||
v-if="isUninstalling"
|
|
||||||
class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm rounded-xl"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3 text-white/90">
|
|
||||||
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm font-medium">{{ t('common.uninstalling') }}...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Uninstall Icon (not for web-only apps) -->
|
<!-- Uninstall Icon (not for web-only apps) -->
|
||||||
<button
|
<button
|
||||||
@ -66,7 +54,7 @@
|
|||||||
{{ description }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="!isInstalling && pkg.state !== 'installing'" class="flex items-center gap-2">
|
<div v-if="!isInstalling && !isUninstalling && pkg.state !== 'installing'" class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
|
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
|
||||||
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
|
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
|
||||||
@ -107,7 +95,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!isUninstalling" class="mt-4 flex gap-2">
|
<!-- Uninstalling progress — replaces action buttons -->
|
||||||
|
<div v-else-if="isUninstalling" class="mt-4">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<svg class="animate-spin h-3 w-3 text-red-400" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs text-red-300">{{ t('common.uninstalling') }}...</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-red-400/60 rounded-full animate-pulse w-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-4 flex gap-2">
|
||||||
<!-- Launch -->
|
<!-- Launch -->
|
||||||
<button
|
<button
|
||||||
v-if="canLaunch(pkg)"
|
v-if="canLaunch(pkg)"
|
||||||
|
|||||||
@ -75,12 +75,13 @@ export function useAppsActions() {
|
|||||||
try {
|
try {
|
||||||
uninstallingApps.add(appId)
|
uninstallingApps.add(appId)
|
||||||
await store.uninstallPackage(appId)
|
await store.uninstallPackage(appId)
|
||||||
// State update comes via WebSocket — no manual deletion needed
|
// Don't clear uninstallingApps here — let the WebSocket watcher clear it
|
||||||
|
// when the container actually disappears from backend data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
|
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
|
||||||
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||||
} finally {
|
|
||||||
uninstallingApps.delete(appId)
|
uninstallingApps.delete(appId)
|
||||||
|
} finally {
|
||||||
uninstalling.value = false
|
uninstalling.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user