Fix config test button lock and add backend timeout

This commit is contained in:
grabbit 2026-02-19 23:18:09 +08:00
parent d5b83a0075
commit 3a858431f1
2 changed files with 250 additions and 27 deletions

View File

@ -9,6 +9,7 @@ use axum::response::Json;
use axum::routing::{get, post};
use axum::Router;
use serde::Serialize;
use tokio::time::{timeout, Duration};
use crate::config::Config;
use crate::daemon::SupervisorCmd;
@ -24,6 +25,8 @@ pub fn routes() -> Router<SharedState> {
.route("/api/logs", get(get_logs))
.route("/api/reconnect/{share}", post(reconnect_share))
.route("/api/preset/{profile}", post(post_preset))
.route("/api/test-connection", post(post_test_connection))
.route("/api/browse", post(post_browse))
}
/// GET /api/status — overall daemon status.
@ -421,6 +424,117 @@ async fn get_logs(
})
}
/// POST /api/test-connection — verify SFTP credentials can connect.
#[derive(serde::Deserialize)]
struct TestConnRequest {
nas_host: String,
nas_user: String,
#[serde(default)]
nas_pass: Option<String>,
#[serde(default)]
nas_key_file: Option<String>,
#[serde(default = "default_sftp_port")]
sftp_port: u16,
}
fn default_sftp_port() -> u16 {
22
}
#[derive(Serialize)]
struct TestConnResponse {
ok: bool,
message: String,
}
const TEST_CONNECTION_TIMEOUT: Duration = Duration::from_secs(12);
async fn post_test_connection(
Json(body): Json<TestConnRequest>,
) -> Json<TestConnResponse> {
let params = crate::rclone::probe::ConnParams {
nas_host: body.nas_host,
nas_user: body.nas_user,
nas_pass: body.nas_pass,
nas_key_file: body.nas_key_file,
sftp_port: body.sftp_port,
};
match timeout(
TEST_CONNECTION_TIMEOUT,
tokio::task::spawn_blocking(move || crate::rclone::probe::test_connection(&params)),
)
.await
{
Ok(Ok(Ok(()))) => Json(TestConnResponse {
ok: true,
message: "Connected".to_string(),
}),
Ok(Ok(Err(e))) => Json(TestConnResponse {
ok: false,
message: e.to_string(),
}),
Ok(Err(e)) => Json(TestConnResponse {
ok: false,
message: format!("Internal error: {e}"),
}),
Err(_) => Json(TestConnResponse {
ok: false,
message: format!(
"Connection test timed out after {}s",
TEST_CONNECTION_TIMEOUT.as_secs()
),
}),
}
}
/// POST /api/browse — list subdirectories at a remote path.
#[derive(serde::Deserialize)]
struct BrowseRequest {
nas_host: String,
nas_user: String,
#[serde(default)]
nas_pass: Option<String>,
#[serde(default)]
nas_key_file: Option<String>,
#[serde(default = "default_sftp_port")]
sftp_port: u16,
#[serde(default = "default_browse_path")]
path: String,
}
fn default_browse_path() -> String {
"/".to_string()
}
#[derive(Serialize)]
struct BrowseResponse {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
dirs: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
async fn post_browse(
Json(body): Json<BrowseRequest>,
) -> Json<BrowseResponse> {
let params = crate::rclone::probe::ConnParams {
nas_host: body.nas_host,
nas_user: body.nas_user,
nas_pass: body.nas_pass,
nas_key_file: body.nas_key_file,
sftp_port: body.sftp_port,
};
let path = body.path;
match tokio::task::spawn_blocking(move || crate::rclone::probe::browse_dirs(&params, &path)).await {
Ok(Ok(dirs)) => Json(BrowseResponse { ok: true, dirs: Some(dirs), error: None }),
Ok(Err(e)) => Json(BrowseResponse { ok: false, dirs: None, error: Some(e.to_string()) }),
Err(e) => Json(BrowseResponse { ok: false, dirs: None, error: Some(format!("Internal error: {e}")) }),
}
}
/// POST /api/reconnect/{share} — trigger reconnect for a single share.
async fn reconnect_share(
State(state): State<SharedState>,

View File

@ -1,12 +1,37 @@
<script id="config-init" type="application/json">{{ init_json }}</script>
<script>
function configEditorFn() {
// Read config synchronously so x-for renders on the first pass.
// If init() sets config *after* Alpine's first scan, x-for elements
// created in the re-render may miss their event-listener binding.
const _initData = JSON.parse(document.getElementById('config-init').textContent);
function _prepareForEdit(config) {
for (const conn of config.connections) {
if (conn.nas_pass == null) conn.nas_pass = '';
if (conn.nas_key_file == null) conn.nas_key_file = '';
}
if (config.smb_auth.username == null) config.smb_auth.username = '';
if (config.smb_auth.smb_pass == null) config.smb_auth.smb_pass = '';
for (const rule of config.warmup.rules) {
if (rule.newer_than == null) rule.newer_than = '';
}
for (const share of config.shares) {
if (share.dir_refresh_interval == null) share.dir_refresh_interval = '';
}
return config;
}
const _config = _prepareForEdit(_initData.config);
return {
config: {},
originalConfig: {},
config: _config,
originalConfig: JSON.parse(JSON.stringify(_config)),
submitting: false,
message: null,
isError: false,
message: _initData.message || null,
isError: _initData.is_error || false,
connTest: {},
browseState: {},
sections: {
connections: true,
shares: true,
@ -25,30 +50,12 @@ function configEditorFn() {
},
init() {
const data = JSON.parse(document.getElementById('config-init').textContent);
this.config = this.prepareForEdit(data.config);
this.originalConfig = JSON.parse(JSON.stringify(this.config));
if (data.message) {
this.message = data.message;
this.isError = data.is_error;
}
// config is already set; nothing to do here.
},
/** Convert null optional fields to empty strings for form binding. */
prepareForEdit(config) {
for (const conn of config.connections) {
if (conn.nas_pass == null) conn.nas_pass = '';
if (conn.nas_key_file == null) conn.nas_key_file = '';
}
if (config.smb_auth.username == null) config.smb_auth.username = '';
if (config.smb_auth.smb_pass == null) config.smb_auth.smb_pass = '';
for (const rule of config.warmup.rules) {
if (rule.newer_than == null) rule.newer_than = '';
}
for (const share of config.shares) {
if (share.dir_refresh_interval == null) share.dir_refresh_interval = '';
}
return config;
return _prepareForEdit(config);
},
/** Convert empty optional strings back to null for the API. */
@ -96,6 +103,79 @@ function configEditorFn() {
});
},
async testConn(conn, i) {
if (this.connTest[i] && this.connTest[i].loading) return;
this.connTest = { ...this.connTest, [i]: { loading: true, ok: null, message: '' } };
try {
const resp = await fetch('/api/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nas_host: conn.nas_host,
nas_user: conn.nas_user,
nas_pass: conn.nas_pass || null,
nas_key_file: conn.nas_key_file || null,
sftp_port: conn.sftp_port || 22,
})
});
const result = await resp.json();
this.connTest = { ...this.connTest, [i]: { loading: false, ok: result.ok, message: result.message } };
} catch (e) {
this.connTest = { ...this.connTest, [i]: { loading: false, ok: false, message: 'Network error: ' + e.message } };
}
},
_connParamsFor(connName) {
const conn = this.config.connections.find(c => c.name === connName);
if (!conn) return null;
return {
nas_host: conn.nas_host,
nas_user: conn.nas_user,
nas_pass: conn.nas_pass || null,
nas_key_file: conn.nas_key_file || null,
sftp_port: conn.sftp_port || 22,
};
},
async browseDir(share, i) {
const path = share.remote_path || '/';
this.browseState = { ...this.browseState, [i]: { dirs: [], loading: true, error: '', path } };
const conn = this.config.connections.find(c => c.name === share.connection);
if (!conn) {
this.browseState = { ...this.browseState, [i]: { dirs: [], loading: false, error: 'Connection not found', path } };
return;
}
try {
const resp = await fetch('/api/browse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nas_host: conn.nas_host,
nas_user: conn.nas_user,
nas_pass: conn.nas_pass || null,
nas_key_file: conn.nas_key_file || null,
sftp_port: conn.sftp_port || 22,
path,
})
});
const result = await resp.json();
if (result.ok) {
this.browseState = { ...this.browseState, [i]: { dirs: result.dirs, loading: false, error: '', path } };
} else {
this.browseState = { ...this.browseState, [i]: { dirs: [], loading: false, error: result.error || 'Error', path } };
}
} catch (e) {
this.browseState = { ...this.browseState, [i]: { dirs: [], loading: false, error: 'Network error: ' + e.message, path } };
}
},
async browseIntoDir(share, i, subdir) {
const base = (this.browseState[i]?.path || '/').replace(/\/+$/, '');
const newPath = base + '/' + subdir;
share.remote_path = newPath;
await this.browseDir({ ...share, remote_path: newPath }, i);
},
async submitConfig() {
this.submitting = true;
this.message = null;
@ -138,7 +218,7 @@ if (window.Alpine) {
}
</script>
<div x-data="configEditor">
<div x-data="configEditorFn()">
<!-- Preset buttons -->
<div class="preset-section">
@ -195,8 +275,16 @@ if (window.Alpine) {
<div class="array-item">
<div class="item-header">
<strong x-text="conn.name || 'New Connection'"></strong>
<div class="item-header-actions">
<button type="button" @click="testConn(conn, i)" class="test-btn">
<span x-show="!(connTest[i] && connTest[i].loading)">Test</span>
<span x-show="connTest[i] && connTest[i].loading" style="display:none">Testing…</span>
</button>
<span x-show="connTest[i] && connTest[i].ok === true" class="test-ok" style="display:none">✓ Connected</span>
<span x-show="connTest[i] && connTest[i].ok === false" class="test-fail" style="display:none" x-text="connTest[i] ? connTest[i].message : ''"></span>
<button type="button" @click="config.connections.splice(i, 1)" class="remove-btn">Remove</button>
</div>
</div>
<div class="field-grid">
<div class="field-row">
<label>Name *</label>
@ -261,7 +349,28 @@ if (window.Alpine) {
</div>
<div class="field-row">
<label>Remote Path *</label>
<input type="text" x-model="share.remote_path" class="mono" required placeholder="/volume1/photos">
<div class="browse-combo">
<input type="text" x-model="share.remote_path" class="mono" required placeholder="/volume1/photos"
@change="browseState = { ...browseState, [i]: null }">
<button type="button" class="browse-btn"
:disabled="(browseState[i] && browseState[i].loading) || !share.connection"
@click="browseDir(share, i)">
<span x-show="!(browseState[i] && browseState[i].loading)">Browse</span>
<span x-show="browseState[i] && browseState[i].loading" style="display:none">Loading…</span>
</button>
</div>
<div x-show="browseState[i] && browseState[i].dirs && browseState[i].dirs.length > 0" class="dir-dropdown">
<template x-for="d in (browseState[i] && browseState[i].dirs || [])" :key="d">
<div class="dir-item">
<span class="dir-name"
@click="share.remote_path = browseState[i].path.replace(/\/+$/, '') + '/' + d; browseState = { ...browseState, [i]: { ...browseState[i], dirs: [] } }"
x-text="d"></span>
<button type="button" class="dir-enter" title="Enter directory"
@click="browseIntoDir(share, i, d)">→</button>
</div>
</template>
</div>
<div x-show="browseState[i] && browseState[i].error" class="browse-error" x-text="browseState[i] ? browseState[i].error : ''"></div>
</div>
<div class="field-row">
<label>Mount Point *</label>