Skip to content

Commit 18bdb39

Browse files
EstrellaXDclaude
andcommitted
feat: add control actions for Docker containers and qBittorrent torrents
Kebab menus on container/torrent cards with context-aware actions, glassmorphic confirmation modals for destructive operations, and backend POST endpoints for start/stop/restart and resume/pause/delete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2c64136 commit 18bdb39

14 files changed

Lines changed: 950 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/).
6+
7+
## [Unreleased]
8+
9+
### Added
10+
- Docker container controls: start, stop, restart via kebab menu on each container card
11+
- qBittorrent torrent controls: resume, pause, delete via kebab menu on each torrent card
12+
- Glassmorphic confirmation modals for destructive actions (stop, restart, delete)
13+
- Delete torrent modal includes "also delete downloaded files" checkbox
14+
- Context-aware menus: only shows applicable actions based on container/torrent state
15+
- Backend POST endpoints for Docker actions (`/api/systems/{id}/actions/docker`)
16+
- Backend POST endpoints for qBittorrent actions (`/api/systems/{id}/actions/qbittorrent`)
17+
- `KebabMenu.vue` and `ConfirmModal.vue` reusable components
18+
- `useActions.ts` composable for action API calls with per-item loading states
19+
- `hash` field on `TorrentInfo` for torrent identification

backend/src/api/routes.rs

Lines changed: 240 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1+
use std::collections::HashMap;
12
use std::path::Path;
23
use std::sync::Arc;
34

45
use axum::extract::{Path as AxumPath, State};
5-
use axum::response::Json;
6+
use axum::http::StatusCode;
7+
use axum::response::{IntoResponse, Json, Response};
8+
use bollard::Docker;
9+
use reqwest::Client;
10+
use serde::Deserialize;
611
use tokio::sync::broadcast;
712

8-
use crate::config::{load_config, Settings};
13+
use crate::config::{
14+
load_config, DockerSystemConfig, QBittorrentSystemConfig, Settings, SystemConfigType,
15+
};
916
use crate::services::collector_manager::CollectorManager;
1017
use crate::services::metrics_store::MetricsStore;
1118

@@ -14,6 +21,7 @@ pub struct AppState {
1421
pub manager: tokio::sync::Mutex<CollectorManager>,
1522
pub settings: Settings,
1623
pub broadcast_tx: broadcast::Sender<String>,
24+
pub system_configs: HashMap<String, SystemConfigType>,
1725
}
1826

1927
pub async fn root() -> Json<serde_json::Value> {
@@ -67,3 +75,233 @@ pub async fn reload_config(
6775
"systems": ids,
6876
}))
6977
}
78+
79+
// --- Action request types ---
80+
81+
#[derive(Deserialize)]
82+
pub struct DockerActionRequest {
83+
pub container_name: String,
84+
pub action: String,
85+
}
86+
87+
#[derive(Deserialize)]
88+
pub struct QbitActionRequest {
89+
pub hash: String,
90+
pub action: String,
91+
#[serde(default)]
92+
pub delete_files: bool,
93+
}
94+
95+
// --- Action error response helper ---
96+
97+
fn action_error(status: StatusCode, msg: &str) -> Response {
98+
(status, Json(serde_json::json!({"error": msg}))).into_response()
99+
}
100+
101+
// --- Docker action handler ---
102+
103+
fn connect_docker(config: &DockerSystemConfig) -> Result<Docker, String> {
104+
if let Some(ref host) = config.host {
105+
Docker::connect_with_http(host, 10, bollard::API_DEFAULT_VERSION)
106+
.map_err(|e| format!("Docker connect failed: {e}"))
107+
} else {
108+
Docker::connect_with_socket(&config.socket, 10, bollard::API_DEFAULT_VERSION)
109+
.map_err(|e| format!("Docker socket connect failed: {e}"))
110+
}
111+
}
112+
113+
pub async fn docker_action(
114+
State(state): State<Arc<AppState>>,
115+
AxumPath(system_id): AxumPath<String>,
116+
Json(body): Json<DockerActionRequest>,
117+
) -> Response {
118+
// Look up Docker config
119+
let config = match state.system_configs.get(&system_id) {
120+
Some(SystemConfigType::Docker(c)) => c.clone(),
121+
_ => return action_error(StatusCode::NOT_FOUND, "Docker system not found"),
122+
};
123+
124+
// Validate action
125+
if !matches!(body.action.as_str(), "start" | "stop" | "restart") {
126+
return action_error(
127+
StatusCode::BAD_REQUEST,
128+
&format!("Invalid action: {}", body.action),
129+
);
130+
}
131+
132+
// Connect
133+
let client = match connect_docker(&config) {
134+
Ok(c) => c,
135+
Err(e) => return action_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
136+
};
137+
138+
// Execute action
139+
let result = match body.action.as_str() {
140+
"start" => client
141+
.start_container::<String>(&body.container_name, None)
142+
.await
143+
.map(|_| ()),
144+
"stop" => client
145+
.stop_container(&body.container_name, None)
146+
.await
147+
.map(|_| ()),
148+
"restart" => client
149+
.restart_container(&body.container_name, None)
150+
.await
151+
.map(|_| ()),
152+
_ => unreachable!(),
153+
};
154+
155+
match result {
156+
Ok(()) => {
157+
tracing::info!(
158+
"[{}] Docker action '{}' on container '{}'",
159+
system_id,
160+
body.action,
161+
body.container_name
162+
);
163+
(
164+
StatusCode::OK,
165+
Json(serde_json::json!({
166+
"status": "success",
167+
"action": body.action,
168+
"container": body.container_name,
169+
})),
170+
)
171+
.into_response()
172+
}
173+
Err(e) => {
174+
tracing::error!(
175+
"[{}] Docker action '{}' failed on '{}': {}",
176+
system_id,
177+
body.action,
178+
body.container_name,
179+
e
180+
);
181+
action_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
182+
}
183+
}
184+
}
185+
186+
// --- qBittorrent action handler ---
187+
188+
async fn qbit_authenticate(client: &Client, config: &QBittorrentSystemConfig) -> Result<(), String> {
189+
let resp = client
190+
.post(format!("{}/api/v2/auth/login", config.url))
191+
.form(&[
192+
("username", config.username.as_str()),
193+
("password", config.password.as_str()),
194+
])
195+
.send()
196+
.await
197+
.map_err(|e| format!("qBittorrent login request failed: {e}"))?;
198+
199+
let text = resp.text().await.map_err(|e| e.to_string())?;
200+
if text != "Ok." {
201+
return Err("qBittorrent authentication failed".to_string());
202+
}
203+
Ok(())
204+
}
205+
206+
pub async fn qbit_action(
207+
State(state): State<Arc<AppState>>,
208+
AxumPath(system_id): AxumPath<String>,
209+
Json(body): Json<QbitActionRequest>,
210+
) -> Response {
211+
// Look up qBit config
212+
let config = match state.system_configs.get(&system_id) {
213+
Some(SystemConfigType::QBittorrent(c)) => c.clone(),
214+
_ => return action_error(StatusCode::NOT_FOUND, "qBittorrent system not found"),
215+
};
216+
217+
// Validate action
218+
if !matches!(body.action.as_str(), "resume" | "pause" | "delete") {
219+
return action_error(
220+
StatusCode::BAD_REQUEST,
221+
&format!("Invalid action: {}", body.action),
222+
);
223+
}
224+
225+
// Create client with cookie store for session
226+
let client = match Client::builder()
227+
.danger_accept_invalid_certs(true)
228+
.cookie_store(true)
229+
.timeout(std::time::Duration::from_secs(10))
230+
.build()
231+
{
232+
Ok(c) => c,
233+
Err(e) => return action_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
234+
};
235+
236+
// Authenticate
237+
if let Err(e) = qbit_authenticate(&client, &config).await {
238+
return action_error(StatusCode::INTERNAL_SERVER_ERROR, &e);
239+
}
240+
241+
// Build the qBittorrent API request
242+
let (endpoint, form_data) = match body.action.as_str() {
243+
"resume" => (
244+
format!("{}/api/v2/torrents/resume", config.url),
245+
vec![("hashes".to_string(), body.hash.clone())],
246+
),
247+
"pause" => (
248+
format!("{}/api/v2/torrents/pause", config.url),
249+
vec![("hashes".to_string(), body.hash.clone())],
250+
),
251+
"delete" => (
252+
format!("{}/api/v2/torrents/delete", config.url),
253+
vec![
254+
("hashes".to_string(), body.hash.clone()),
255+
("deleteFiles".to_string(), body.delete_files.to_string()),
256+
],
257+
),
258+
_ => unreachable!(),
259+
};
260+
261+
// Execute
262+
let result = client.post(&endpoint).form(&form_data).send().await;
263+
264+
match result {
265+
Ok(resp) if resp.status().is_success() => {
266+
tracing::info!(
267+
"[{}] qBit action '{}' on torrent '{}'",
268+
system_id,
269+
body.action,
270+
body.hash
271+
);
272+
(
273+
StatusCode::OK,
274+
Json(serde_json::json!({
275+
"status": "success",
276+
"action": body.action,
277+
"hash": body.hash,
278+
})),
279+
)
280+
.into_response()
281+
}
282+
Ok(resp) => {
283+
let status = resp.status();
284+
let text = resp.text().await.unwrap_or_default();
285+
tracing::error!(
286+
"[{}] qBit action '{}' failed: {} {}",
287+
system_id,
288+
body.action,
289+
status,
290+
text
291+
);
292+
action_error(
293+
StatusCode::INTERNAL_SERVER_ERROR,
294+
&format!("qBittorrent API returned {status}"),
295+
)
296+
}
297+
Err(e) => {
298+
tracing::error!(
299+
"[{}] qBit action '{}' request failed: {}",
300+
system_id,
301+
body.action,
302+
e
303+
);
304+
action_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
305+
}
306+
}
307+
}

backend/src/collectors/mock.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ impl Collector for MockQBittorrentCollector {
281281
let mut total_download_speed: i64 = 0;
282282
let mut total_upload_speed: i64 = 0;
283283

284-
for t in &mut self.torrents {
284+
for (idx, t) in self.torrents.iter_mut().enumerate() {
285285
if t.progress < 100.0 && t.state == "downloading" {
286286
t.progress = (t.progress + rng.gen_range(0.1..0.5)).min(100.0);
287287
if t.progress >= 100.0 {
@@ -319,6 +319,7 @@ impl Collector for MockQBittorrentCollector {
319319
};
320320

321321
torrent_infos.push(TorrentInfo {
322+
hash: format!("{:032x}", t.name.len() as u128 * 0xdeadbeef + idx as u128),
322323
name: t.name.to_string(),
323324
size: t.size,
324325
progress: t.progress,

backend/src/collectors/qbittorrent.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ impl QBittorrentCollector {
9999
};
100100

101101
torrents.push(TorrentInfo {
102+
hash: torrent["hash"].as_str().unwrap_or("").to_string(),
102103
name: torrent["name"].as_str().unwrap_or("Unknown").to_string(),
103104
size: torrent["size"].as_i64().unwrap_or(0),
104105
progress: torrent["progress"].as_f64().unwrap_or(0.0) * 100.0,

backend/src/main.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ use axum::Router;
66
use tokio::sync::broadcast;
77
use tower_http::cors::{Any, CorsLayer};
88

9+
use std::collections::HashMap;
10+
911
use server_monitor::api::{routes, websocket};
1012
use server_monitor::config::{load_config, Settings};
1113
use server_monitor::services::collector_manager::CollectorManager;
@@ -44,11 +46,20 @@ async fn main() {
4446

4547
let collector_count = manager.collector_count();
4648

49+
// Build system config map for action handlers
50+
let system_configs: HashMap<String, _> = config
51+
.systems
52+
.iter()
53+
.filter(|s| s.enabled)
54+
.map(|s| (s.id.clone(), s.config.clone()))
55+
.collect();
56+
4757
let state = Arc::new(routes::AppState {
4858
store,
4959
manager: tokio::sync::Mutex::new(manager),
5060
settings: settings.clone(),
5161
broadcast_tx: tx,
62+
system_configs,
5263
});
5364

5465
// Start collection loop
@@ -70,6 +81,14 @@ async fn main() {
7081
.route("/api/systems/{system_id}", get(routes::get_system))
7182
.route("/api/health", get(routes::health_check))
7283
.route("/api/reload", post(routes::reload_config))
84+
.route(
85+
"/api/systems/{system_id}/actions/docker",
86+
post(routes::docker_action),
87+
)
88+
.route(
89+
"/api/systems/{system_id}/actions/qbittorrent",
90+
post(routes::qbit_action),
91+
)
7392
.route("/ws", get(websocket::ws_handler))
7493
.layer(cors)
7594
.with_state(state.clone());

backend/src/models/metrics.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ pub struct DockerMetrics {
9898

9999
#[derive(Debug, Clone, Serialize, Deserialize)]
100100
pub struct TorrentInfo {
101+
pub hash: String,
101102
pub name: String,
102103
pub size: i64,
103104
pub progress: f64,

0 commit comments

Comments
 (0)