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::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use tokio::time::{timeout, Duration};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::daemon::SupervisorCmd;
|
use crate::daemon::SupervisorCmd;
|
||||||
@ -24,6 +25,8 @@ pub fn routes() -> Router<SharedState> {
|
|||||||
.route("/api/logs", get(get_logs))
|
.route("/api/logs", get(get_logs))
|
||||||
.route("/api/reconnect/{share}", post(reconnect_share))
|
.route("/api/reconnect/{share}", post(reconnect_share))
|
||||||
.route("/api/preset/{profile}", post(post_preset))
|
.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.
|
/// 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.
|
/// POST /api/reconnect/{share} — trigger reconnect for a single share.
|
||||||
async fn reconnect_share(
|
async fn reconnect_share(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
|
|||||||
@ -1,12 +1,37 @@
|
|||||||
<script id="config-init" type="application/json">{{ init_json }}</script>
|
<script id="config-init" type="application/json">{{ init_json }}</script>
|
||||||
<script>
|
<script>
|
||||||
function configEditorFn() {
|
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 {
|
return {
|
||||||
config: {},
|
config: _config,
|
||||||
originalConfig: {},
|
originalConfig: JSON.parse(JSON.stringify(_config)),
|
||||||
submitting: false,
|
submitting: false,
|
||||||
message: null,
|
message: _initData.message || null,
|
||||||
isError: false,
|
isError: _initData.is_error || false,
|
||||||
|
connTest: {},
|
||||||
|
browseState: {},
|
||||||
sections: {
|
sections: {
|
||||||
connections: true,
|
connections: true,
|
||||||
shares: true,
|
shares: true,
|
||||||
@ -25,30 +50,12 @@ function configEditorFn() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
const data = JSON.parse(document.getElementById('config-init').textContent);
|
// config is already set; nothing to do here.
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Convert null optional fields to empty strings for form binding. */
|
/** Convert null optional fields to empty strings for form binding. */
|
||||||
prepareForEdit(config) {
|
prepareForEdit(config) {
|
||||||
for (const conn of config.connections) {
|
return _prepareForEdit(config);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Convert empty optional strings back to null for the API. */
|
/** 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() {
|
async submitConfig() {
|
||||||
this.submitting = true;
|
this.submitting = true;
|
||||||
this.message = null;
|
this.message = null;
|
||||||
@ -138,7 +218,7 @@ if (window.Alpine) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div x-data="configEditor">
|
<div x-data="configEditorFn()">
|
||||||
|
|
||||||
<!-- Preset buttons -->
|
<!-- Preset buttons -->
|
||||||
<div class="preset-section">
|
<div class="preset-section">
|
||||||
@ -195,7 +275,15 @@ if (window.Alpine) {
|
|||||||
<div class="array-item">
|
<div class="array-item">
|
||||||
<div class="item-header">
|
<div class="item-header">
|
||||||
<strong x-text="conn.name || 'New Connection'"></strong>
|
<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>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
@ -261,7 +349,28 @@ if (window.Alpine) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<label>Remote Path *</label>
|
<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>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<label>Mount Point *</label>
|
<label>Mount Point *</label>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user