Fix config test button lock and add backend timeout
This commit is contained in:
parent
d5b83a0075
commit
3a858431f1
114
src/web/api.rs
114
src/web/api.rs
@ -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(¶ms)),
|
||||
)
|
||||
.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(¶ms, &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>,
|
||||
|
||||
@ -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,7 +275,15 @@ if (window.Alpine) {
|
||||
<div class="array-item">
|
||||
<div class="item-header">
|
||||
<strong x-text="conn.name || 'New Connection'"></strong>
|
||||
<button type="button" @click="config.connections.splice(i, 1)" class="remove-btn">Remove</button>
|
||||
<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">
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user