Compare commits

...

6 Commits

Author SHA1 Message Date
Shaun Arman
4d066e47fd docs: update ticket to include VM listing and module-wide double-unwrap fixes
Some checks failed
Test / frontend-tests (pull_request) Successful in 1m52s
Test / frontend-typecheck (pull_request) Successful in 2m1s
PR Review Automation / review (pull_request) Failing after 8m51s
Test / rust-fmt-check (pull_request) Failing after 14m51s
Test / rust-clippy (pull_request) Has been cancelled
Test / rust-tests (pull_request) Has been cancelled
2026-06-20 19:38:49 -05:00
Shaun Arman
9687f97d7c fix(proxmox): remove double-unwrap of Proxmox data envelope across all modules
handle_response() in client.rs already strips the {"data":...} wrapper
before returning to callers. Every proxmox module was calling .get("data")
a second time on the already-unwrapped Value, which always returned None
and caused all API responses to silently yield empty results or errors.

vm.rs had an additional bug: list_vms used POST on cluster/resources (a
GET-only endpoint) and dropped VMs with no cpu field via filter_map ?
instead of unwrap_or(0.0). Both corrected.

Affected modules: vm, ceph, ceph_cluster, certificates, acme, firewall,
sdn, ha, apt, updates, updates_ext, tasks, migration, metrics, shell,
auth_realm, views, backup — 18 files, 19 functions.

426 Rust tests pass. clippy -D warnings clean. tsc --noEmit clean.
2026-06-20 19:38:49 -05:00
Shaun Arman
b091602741 fix(proxmox): restore reliable connect/reconnect after app restart
Root cause: authenticate() tried to deserialize the Proxmox API response
directly into AuthResponse, but Proxmox wraps every response in
{"data": {...}}.  This caused every reconnect attempt after app restart
to fail silently.

Additional fixes bundled in this commit:
- add_proxmox_cluster now authenticates immediately so the in-memory pool
  always contains a live, ticketed client (not a bare unauthenticated stub)
- ProxmoxClient stores the CSRFPreventionToken and includes it in the
  CSRFPreventionToken header on POST/PUT/DELETE requests (Proxmox requires
  this for all mutating calls)
- accept-invalid-certs enabled on the reqwest Client so self-signed PVE
  certificates do not block connections
- Removed double-unwrap of the data field in 10 commands (list_acls,
  list_users, get_cluster_notes, search_proxmox_resources, get_node_status,
  get_syslog, list_network_interfaces, get_subscription_status,
  list_cluster_tasks, list_proxmox_containers) — handle_response already
  strips the envelope before returning to callers
- Added connect_proxmox_cluster and disconnect_proxmox_cluster Tauri
  commands so the UI can explicitly connect/disconnect sessions
- Wired RemotesPage Connect/Disconnect buttons to the real backend commands
- Updated and added tests covering envelope parsing, CSRF header logic,
  already-unwrapped response handling, and the new connect/disconnect paths
2026-06-20 19:38:49 -05:00
cb770661d7 Merge pull request 'fix: Proxmox v1.2.2 — client retrieval, AI message ordering, Remotes UX, Ceph false positive' (#124) from fix/proxmox-v1.2.2-consolidated into beta
Some checks failed
Release Beta / autotag (push) Successful in 10s
Release Beta / changelog (push) Successful in 1m35s
Test / frontend-tests (push) Successful in 1m45s
Test / frontend-typecheck (push) Successful in 1m57s
Release Beta / build-linux-amd64 (push) Successful in 12m7s
Release Beta / build-windows-amd64 (push) Successful in 12m28s
Release Beta / build-linux-arm64 (push) Successful in 13m48s
Release Beta / build-macos-arm64 (push) Failing after 21m38s
Test / rust-fmt-check (push) Successful in 23m18s
Test / rust-clippy (push) Successful in 24m34s
Test / rust-tests (push) Successful in 26m42s
Reviewed-on: #124
2026-06-20 13:02:35 +00:00
Shaun Arman
68439bcd64 fix: address PR review findings — race condition, real ping, atomic edit, listener cleanup
All checks were successful
Test / frontend-typecheck (pull_request) Successful in 2m19s
Test / frontend-tests (pull_request) Successful in 2m10s
PR Review Automation / review (pull_request) Successful in 8m50s
Test / rust-fmt-check (pull_request) Successful in 16m20s
Test / rust-clippy (pull_request) Successful in 17m51s
Test / rust-tests (pull_request) Successful in 19m20s
Race condition in get_proxmox_client_for_cluster: two concurrent callers
for an uncached cluster could both authenticate and insert, with the second
overwriting the first. Re-check under write lock before inserting so the
later caller returns the already-stored client instead of overwriting it.

handleConnectRemote used getProxmoxCluster (a DB-only lookup) to set status
'connected', which passed even when the Proxmox API was unreachable. Replace
with pingProxmoxCluster, a new command that authenticates and calls
GET /api2/json/version, providing a real end-to-end connectivity test.

handleEditRemote used remove-then-add, leaving a gap where the record was
absent and silently lost if addProxmoxCluster failed. Replace with
updateProxmoxCluster, a new command that issues a single SQL UPDATE (plus
in-memory pool eviction) so the record is never transiently missing.

ActionsMenu useEffect added the mousedown listener only when open=true but
the dependency array contained open, causing ambiguity about cleanup timing.
Attach the listener unconditionally on mount (empty dep array) so there is
always exactly one add and one remove with no conditional branches.

New Rust tests cover update_proxmox_cluster not-found logic and ping error
message format (420 Rust + 386 frontend, zero failures).
2026-06-19 22:26:33 -05:00
Shaun Arman
c5b97f8648 fix(proxmox): restore broken client retrieval across all commands
All checks were successful
Test / frontend-tests (pull_request) Successful in 1m36s
Test / frontend-typecheck (pull_request) Successful in 1m46s
PR Review Automation / review (pull_request) Successful in 5m8s
Test / rust-fmt-check (pull_request) Successful in 12m30s
Test / rust-clippy (pull_request) Successful in 14m10s
Test / rust-tests (pull_request) Successful in 16m35s
Half-completed refactor left 68 Tauri command functions with orphaned
.ok_or_else() chains after the old clusters.get() pattern was removed
without inserting the replacement helper call. Also fixed two bugs in the
new get_proxmox_client_for_cluster helper: undeclared `clusters` variable
in the early-return check, and client_arc going out of scope before return.

fix(ai): enforce system-message-first ordering for strict LLM providers

Qwen3.5-122b (and other models via LiteLLM) reject requests where system
messages appear after user/assistant turns. Moved tool-calling format
and iteration-budget system messages to before history is appended.
Changed mid-loop iteration warning and forced-stop instruction from
system role to user role so they can safely appear mid-conversation.

fix(proxmox): Remotes actions menu and connect/disconnect behaviour

Replaced the non-functional "..." toast placeholder with a proper
ActionsMenu dropdown (Edit / Test Connection / Delete). Removed inline
emoji buttons folded into the menu. Connect now calls getProxmoxCluster
as a live connection test and reflects real status; disconnect marks the
remote disconnected locally. Remote status now maps correctly from the
backend ClusterInfoWithHealth.connected field instead of hardcoding
'connected' for every entry.

fix(proxmox): Ceph page no longer shows HEALTH_OK on non-Ceph clusters

Page now fetches real health data on mount. If getCephHealth fails the
page renders an informational notice rather than fake HEALTH_OK. When
Ceph is present, pools and OSDs are loaded and displayed live.
2026-06-19 22:13:48 -05:00
28 changed files with 1125 additions and 616 deletions

View File

@ -0,0 +1,90 @@
# fix(proxmox): Reliable connect/reconnect after app restart
## Description
Adding a new Proxmox host reported "connected" immediately, but after closing
the app or clicking Disconnect the host could not be reconnected.
Three compounding bugs caused this:
1. **`authenticate()` could never succeed on reconnect** — `ProxmoxClient::authenticate()`
called `response.json::<AuthResponse>()` but the Proxmox API wraps every
response in `{"data": {...}}`. The deserialiser always failed with "missing
field `ticket`", so `get_proxmox_client_for_cluster` (and the new
`connect_proxmox_cluster`) threw an error on every app-restart reconnect.
2. **False "connected" indicator on add**`add_proxmox_cluster` inserted an
*unauthenticated* client (no ticket) into the in-memory pool.
`list_proxmox_clusters` reported `connected: true` just because the HashMap
key existed, even though any API call would have failed.
3. **Double-unwrap of `data` in 10 commands**`handle_response` already
strips the `{"data": ...}` envelope before returning to callers, but
`list_acls`, `list_users`, `get_cluster_notes`, `search_proxmox_resources`,
`get_node_status`, `get_syslog`, `list_network_interfaces`,
`get_subscription_status`, `list_cluster_tasks`, and
`list_proxmox_containers` all called `.get("data")` on the already-unwrapped
value, causing them to always return "Invalid response format".
4. **VM list always empty**`list_vms` in `vm.rs` used `POST` on
`cluster/resources` (a GET-only endpoint). The Proxmox API ignores the
POST body and the function also had the same double-unwrap bug, meaning
the resource list always came back empty. Additionally, VMs with no
`cpu` field (e.g. stopped VMs) were silently dropped by `filter_map`
using `?` — fixed to `unwrap_or(0.0)`.
5. **Double-unwrap in all other proxmox modules** — the same `.get("data")`
double-unwrap was present across 18 module files (ceph, ceph_cluster,
certificates, acme, firewall, sdn, ha, apt, updates, updates_ext, tasks,
migration, metrics, shell, auth_realm, views, backup, vm). All 19 affected
functions fixed in a single follow-up commit.
Additional gaps addressed:
- `CSRFPreventionToken` was never sent on POST/PUT/DELETE, so all mutating
operations (VM start/stop, firewall rules, etc.) would fail with
"CSRF check failed".
- Disconnect was UI-only with no backend call — the session stayed in the pool.
- `reqwest::Client` rejected self-signed Proxmox certificates.
- No `connect_proxmox_cluster` / `disconnect_proxmox_cluster` backend commands
existed; the Connect/Disconnect buttons in the Remotes page were wired to a
non-existent `ping_proxmox_cluster` or nothing at all.
## Acceptance Criteria
- [ ] Adding a new Proxmox host authenticates immediately; the UI shows
"connected" only when a real ticket has been obtained.
- [ ] Closing the app and re-opening it allows reconnecting via the Connect
button without requiring the host to be removed and re-added.
- [ ] Clicking Disconnect removes the session from the backend pool; subsequent
API calls fail with "Cluster not found" until Connect is clicked.
- [ ] All existing Rust tests (426) and frontend tests (386) continue to pass.
- [ ] `cargo clippy -- -D warnings` and `npx eslint . --max-warnings 0` report
zero issues.
## Work Implemented
| File | Change |
|------|--------|
| `src-tauri/src/proxmox/client.rs` | Added `ProxmoxEnvelope<T>` wrapper; fixed `authenticate()` to use `&mut self`, parse the envelope, and store both ticket and CSRF token; added `set_csrf_token()`; `build_headers()` now takes `include_csrf` flag; POST/PUT/DELETE pass `true`; `danger_accept_invalid_certs(true)` on the reqwest client |
| `src-tauri/src/commands/proxmox.rs` | `add_proxmox_cluster` authenticates before inserting into pool; fixed double-unwrap in 10 commands; added `connect_proxmox_cluster` and `disconnect_proxmox_cluster` Tauri commands |
| `src-tauri/src/lib.rs` | Registered `connect_proxmox_cluster` and `disconnect_proxmox_cluster` |
| `src-tauri/src/cli/mod.rs` | `let mut client``authenticate` is now `&mut self` |
| `src/lib/proxmoxClient.ts` | Added `connectProxmoxCluster` and `disconnectProxmoxCluster` wrappers |
| `src/pages/Proxmox/RemotesPage.tsx` | Added `handleConnectRemote` / `handleDisconnectRemote` handlers calling real backend commands; wired them to `RemotesList` `onConnect` / `onDisconnect` props |
| `src-tauri/src/proxmox/vm.rs` | `list_vms`: changed from `client.post("cluster/resources", body)` to `client.get("cluster/resources?type=vm")`; removed double-unwrap; cpu uses `unwrap_or(0.0)`. `get_vm`: removed double-unwrap. `list_snapshots`: removed double-unwrap. |
| `src-tauri/src/proxmox/{acme,apt,auth_realm,backup,ceph,ceph_cluster,certificates,firewall,ha,metrics,migration,sdn,shell,tasks,updates,updates_ext,views}.rs` | Removed `.get("data")` double-unwrap from all 19 functions across 17 files. |
New tests added:
- `client.rs` — envelope deserialization, no-CSRF path, `build_headers` GET omits / POST includes CSRF token, `set_ticket`/`set_csrf_token`
- `commands/proxmox.rs` — already-unwrapped array/object/notes response handling, `connect_proxmox_cluster` not-found error message
## Testing Needed
- [ ] Add a new Proxmox host with correct credentials → verify "connected" status
- [ ] Add a host with wrong password → verify immediate auth error, host not saved
- [ ] Restart the app → verify all previously-connected hosts show "disconnected"
- [ ] Click Connect on a disconnected host → verify it re-authenticates and shows "connected"
- [ ] Click Disconnect → verify status changes to "disconnected" and subsequent API calls fail
- [ ] Verify a VM start/stop operation succeeds (exercises CSRF token flow)
- [ ] Verify `get_node_status`, `list_acls`, `get_syslog` return real data (exercises double-unwrap fix)
- [ ] Verify a Proxmox host with a self-signed certificate connects successfully

View File

@ -44,7 +44,7 @@ impl Cli {
async fn main() { async fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
let client = crate::proxmox::client::ProxmoxClient::new(&cli.url, 8006, &cli.username); let mut client = crate::proxmox::client::ProxmoxClient::new(&cli.url, 8006, &cli.username);
let ticket = match client.authenticate(&cli.password).await { let ticket = match client.authenticate(&cli.password).await {
Ok(t) => t, Ok(t) => t,

View File

@ -351,9 +351,11 @@ pub async fn chat_message(
let agent_registry = create_agent_registry(); let agent_registry = create_agent_registry();
let devops_agent = agent_registry.get("devops-incident-responder"); let devops_agent = agent_registry.get("devops-incident-responder");
// CRITICAL: Build messages array with ALL system messages FIRST, then history, then user message
// This ensures system messages are always at the beginning as required by most LLM APIs
let mut messages = Vec::new(); let mut messages = Vec::new();
// Inject devops-incident-responder as primary system prompt (always) // 1. Inject devops-incident-responder as primary system prompt (always first)
if let Some(agent) = devops_agent { if let Some(agent) = devops_agent {
messages.push(Message { messages.push(Message {
role: "system".into(), role: "system".into(),
@ -363,7 +365,7 @@ pub async fn chat_message(
}); });
} }
// Inject domain system prompt if provided // 2. Inject domain system prompt if provided (second position)
if let Some(ref prompt) = system_prompt { if let Some(ref prompt) = system_prompt {
if !prompt.is_empty() { if !prompt.is_empty() {
messages.push(Message { messages.push(Message {
@ -375,28 +377,6 @@ pub async fn chat_message(
} }
} }
messages.extend(history);
// If we found integration content, add it to the conversation context
if !integration_context.is_empty() {
let context_message = Message {
role: "system".into(),
content: format!(
"INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\
Instructions: The above content is from internal company documentation systems \
(Confluence, ServiceNow, Azure DevOps). \
\n\n**IMPORTANT**: First determine if this documentation is RELEVANT to the user's question:\
\n- If the documentation directly addresses the question Use it and cite sources with URLs\
\n- If the documentation is tangentially related but doesn't answer the question Briefly mention what internal docs exist, then provide a complete answer using general knowledge\
\n- If the documentation is completely unrelated Ignore it and answer using general knowledge\
\n\nDo NOT force irrelevant internal documentation into your answer. The user needs accurate information, not forced citations."
),
tool_call_id: None,
tool_calls: None,
};
messages.push(context_message);
}
// Tool execution configuration // Tool execution configuration
const MAX_TOOL_ITERATIONS: usize = 20; // Allow sufficient iterations for complex diagnostics const MAX_TOOL_ITERATIONS: usize = 20; // Allow sufficient iterations for complex diagnostics
@ -427,6 +407,7 @@ pub async fn chat_message(
!matches!(kind, "anthropic" | "gemini" | "mistral" | "ollama") !matches!(kind, "anthropic" | "gemini" | "mistral" | "ollama")
}; };
// 3. Tool-calling system messages — must come BEFORE history so all system messages are contiguous
if tools.is_some() && is_openai_compatible { if tools.is_some() && is_openai_compatible {
messages.push(Message { messages.push(Message {
role: "system".into(), role: "system".into(),
@ -435,7 +416,6 @@ pub async fn chat_message(
tool_calls: None, tool_calls: None,
}); });
// Add iteration budget awareness
messages.push(Message { messages.push(Message {
role: "system".into(), role: "system".into(),
content: format!( content: format!(
@ -454,6 +434,34 @@ pub async fn chat_message(
}); });
} }
// 4. Integration context as system message — still before history
if !integration_context.is_empty() {
messages.push(Message {
role: "system".into(),
content: format!(
"INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\
Instructions: The above content is from internal company documentation systems \
(Confluence, ServiceNow, Azure DevOps). \
\n\n**IMPORTANT**: First determine if this documentation is RELEVANT to the user's question:\
\n- If the documentation directly addresses the question Use it and cite sources with URLs\
\n- If the documentation is tangentially related but doesn't answer the question Briefly mention what internal docs exist, then provide a complete answer using general knowledge\
\n- If the documentation is completely unrelated Ignore it and answer using general knowledge\
\n\nDo NOT force irrelevant internal documentation into your answer. The user needs accurate information, not forced citations."
),
tool_call_id: None,
tool_calls: None,
});
}
// 5. Filter out any system messages from history to avoid duplicates and maintain order
let filtered_history: Vec<Message> = history
.into_iter()
.filter(|msg| msg.role != "system")
.collect();
// 6. Add filtered history (user/assistant messages only) — all system messages are already above
messages.extend(filtered_history);
messages.push(Message { messages.push(Message {
role: "user".into(), role: "user".into(),
content: full_message.clone(), content: full_message.clone(),
@ -471,7 +479,7 @@ pub async fn chat_message(
// Warn AI when approaching limit // Warn AI when approaching limit
if iteration == MAX_TOOL_ITERATIONS - 2 { if iteration == MAX_TOOL_ITERATIONS - 2 {
messages.push(Message { messages.push(Message {
role: "system".into(), role: "user".into(),
content: format!( content: format!(
"WARNING: You are on iteration {iteration}/{MAX_TOOL_ITERATIONS} (2 rounds remaining). \ "WARNING: You are on iteration {iteration}/{MAX_TOOL_ITERATIONS} (2 rounds remaining). \
You MUST provide your final answer in the NEXT round. \ You MUST provide your final answer in the NEXT round. \
@ -490,7 +498,7 @@ pub async fn chat_message(
// Add final instruction // Add final instruction
let mut final_messages = sanitized_messages; let mut final_messages = sanitized_messages;
final_messages.push(Message { final_messages.push(Message {
role: "system".into(), role: "user".into(),
content: format!( content: format!(
"CRITICAL: Tool iteration limit reached ({iteration}/{MAX_TOOL_ITERATIONS}). \ "CRITICAL: Tool iteration limit reached ({iteration}/{MAX_TOOL_ITERATIONS}). \
TOOLS ARE NOW DISABLED. \ TOOLS ARE NOW DISABLED. \

File diff suppressed because it is too large Load Diff

View File

@ -225,6 +225,10 @@ pub fn run() {
// Proxmox - Existing // Proxmox - Existing
commands::proxmox::add_proxmox_cluster, commands::proxmox::add_proxmox_cluster,
commands::proxmox::remove_proxmox_cluster, commands::proxmox::remove_proxmox_cluster,
commands::proxmox::update_proxmox_cluster,
commands::proxmox::ping_proxmox_cluster,
commands::proxmox::connect_proxmox_cluster,
commands::proxmox::disconnect_proxmox_cluster,
commands::proxmox::list_proxmox_clusters, commands::proxmox::list_proxmox_clusters,
commands::proxmox::get_proxmox_cluster, commands::proxmox::get_proxmox_cluster,
commands::proxmox::list_proxmox_vms, commands::proxmox::list_proxmox_vms,

View File

@ -44,7 +44,7 @@ pub async fn list_acme_accounts(
.await .await
.map_err(|e| format!("Failed to list ACME accounts: {}", e))?; .map_err(|e| format!("Failed to list ACME accounts: {}", e))?;
if let Some(accounts) = response.get("data").and_then(|d| d.as_array()) { if let Some(accounts) = response.as_array() {
let account_list: Vec<AcmeAccount> = accounts let account_list: Vec<AcmeAccount> = accounts
.iter() .iter()
.filter_map(|account| { .filter_map(|account| {
@ -94,7 +94,8 @@ pub async fn register_acme_account(
.await .await
.map_err(|e| format!("Failed to register ACME account: {}", e))?; .map_err(|e| format!("Failed to register ACME account: {}", e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let id = data let id = data
.get("id") .get("id")
.and_then(|i| i.as_str()) .and_then(|i| i.as_str())
@ -117,8 +118,6 @@ pub async fn register_acme_account(
status, status,
created_at, created_at,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }
@ -134,7 +133,7 @@ pub async fn get_acme_challenges(
.await .await
.map_err(|e| format!("Failed to get ACME challenges for {}: {}", domain, e))?; .map_err(|e| format!("Failed to get ACME challenges for {}: {}", domain, e))?;
if let Some(challenges) = response.get("data").and_then(|d| d.as_array()) { if let Some(challenges) = response.as_array() {
let challenge_list: Vec<AcmeChallenge> = challenges let challenge_list: Vec<AcmeChallenge> = challenges
.iter() .iter()
.filter_map(|challenge| { .filter_map(|challenge| {
@ -191,7 +190,8 @@ pub async fn request_certificate(
.await .await
.map_err(|e| format!("Failed to request ACME certificate: {}", e))?; .map_err(|e| format!("Failed to request ACME certificate: {}", e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let id = data let id = data
.get("id") .get("id")
.and_then(|i| i.as_str()) .and_then(|i| i.as_str())
@ -230,8 +230,6 @@ pub async fn request_certificate(
expires_at, expires_at,
issuer, issuer,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }
@ -247,7 +245,8 @@ pub async fn get_certificate_details(
.await .await
.map_err(|e| format!("Failed to get ACME certificate {}: {}", cert_id, e))?; .map_err(|e| format!("Failed to get ACME certificate {}: {}", cert_id, e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let id = data let id = data
.get("id") .get("id")
.and_then(|i| i.as_str()) .and_then(|i| i.as_str())
@ -286,8 +285,6 @@ pub async fn get_certificate_details(
expires_at, expires_at,
issuer, issuer,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }

View File

@ -37,8 +37,7 @@ pub async fn list_apt_updates(
.map_err(|e| format!("Failed to list APT updates: {}", e))?; .map_err(|e| format!("Failed to list APT updates: {}", e))?;
let updates: Vec<APTUpdate> = response let updates: Vec<APTUpdate> = response
.get("data") .as_array()
.and_then(|d| d.as_array())
.map(|arr| { .map(|arr| {
arr.iter() arr.iter()
.filter_map(|update| { .filter_map(|update| {
@ -97,7 +96,9 @@ pub async fn list_apt_repositories(
.await .await
.map_err(|e| format!("Failed to list APT repositories: {}", e))?; .map_err(|e| format!("Failed to list APT repositories: {}", e))?;
if let Some(repos) = response.get("data").and_then(|d| d.as_array()) { let repos = response
.as_array()
.ok_or_else(|| "Invalid response format: expected array".to_string())?;
let repo_list: Vec<APTRepository> = repos let repo_list: Vec<APTRepository> = repos
.iter() .iter()
.filter_map(|repo| { .filter_map(|repo| {
@ -135,9 +136,6 @@ pub async fn list_apt_repositories(
.collect(); .collect();
Ok(repo_list) Ok(repo_list)
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
} }
/// Add APT repository /// Add APT repository

View File

@ -62,7 +62,7 @@ pub async fn list_auth_realms(
.await .await
.map_err(|e| format!("Failed to list authentication realms: {}", e))?; .map_err(|e| format!("Failed to list authentication realms: {}", e))?;
if let Some(realms) = response.get("data").and_then(|d| d.as_array()) { if let Some(realms) = response.as_array() {
let realm_list: Vec<AuthRealm> = realms let realm_list: Vec<AuthRealm> = realms
.iter() .iter()
.filter_map(|realm| { .filter_map(|realm| {
@ -89,7 +89,7 @@ pub async fn list_auth_realms(
Ok(realm_list) Ok(realm_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Ok(vec![])
} }
} }

View File

@ -38,7 +38,7 @@ pub async fn list_backup_jobs(
.await .await
.map_err(|e| format!("Failed to list backup jobs: {}", e))?; .map_err(|e| format!("Failed to list backup jobs: {}", e))?;
if let Some(jobs) = response.get("data").and_then(|d| d.as_array()) { if let Some(jobs) = response.as_array() {
let backup_jobs: Vec<BackupJob> = jobs let backup_jobs: Vec<BackupJob> = jobs
.iter() .iter()
.filter_map(|job| { .filter_map(|job| {
@ -64,7 +64,7 @@ pub async fn list_backup_jobs(
Ok(backup_jobs) Ok(backup_jobs)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Ok(vec![])
} }
} }
@ -159,7 +159,7 @@ pub async fn list_datastores(
.await .await
.map_err(|e| format!("Failed to list datastores: {}", e))?; .map_err(|e| format!("Failed to list datastores: {}", e))?;
if let Some(datastores) = response.get("data").and_then(|d| d.as_array()) { if let Some(datastores) = response.as_array() {
let datastore_list: Vec<DatastoreInfo> = datastores let datastore_list: Vec<DatastoreInfo> = datastores
.iter() .iter()
.filter_map(|ds| { .filter_map(|ds| {
@ -183,7 +183,7 @@ pub async fn list_datastores(
Ok(datastore_list) Ok(datastore_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Ok(vec![])
} }
} }
@ -200,7 +200,7 @@ pub async fn get_datastore_status(
.await .await
.map_err(|e| format!("Failed to get datastore status: {}", e))?; .map_err(|e| format!("Failed to get datastore status: {}", e))?;
let ds = response.get("data").ok_or("Invalid response format")?; let ds = &response;
Ok(DatastoreInfo { Ok(DatastoreInfo {
datastore: datastore.to_string(), datastore: datastore.to_string(),
@ -229,10 +229,10 @@ pub async fn list_backup_snapshots(
.await .await
.map_err(|e| format!("Failed to list backup snapshots: {}", e))?; .map_err(|e| format!("Failed to list backup snapshots: {}", e))?;
if let Some(snapshots) = response.get("data").and_then(|d| d.as_array()) { if let Some(snapshots) = response.as_array() {
Ok(snapshots.to_vec()) Ok(snapshots.to_vec())
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Ok(vec![])
} }
} }

View File

@ -55,7 +55,7 @@ pub async fn list_pools(
.await .await
.map_err(|e| format!("Failed to list Ceph pools: {}", e))?; .map_err(|e| format!("Failed to list Ceph pools: {}", e))?;
if let Some(pools) = response.get("data").and_then(|d| d.as_array()) { if let Some(pools) = response.as_array() {
let pool_list: Vec<CephPool> = pools let pool_list: Vec<CephPool> = pools
.iter() .iter()
.filter_map(|pool| { .filter_map(|pool| {
@ -87,7 +87,7 @@ pub async fn list_pools(
Ok(pool_list) Ok(pool_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }
@ -155,7 +155,7 @@ pub async fn list_osds(
.await .await
.map_err(|e| format!("Failed to list Ceph OSDs: {}", e))?; .map_err(|e| format!("Failed to list Ceph OSDs: {}", e))?;
if let Some(osds) = response.get("data").and_then(|d| d.as_array()) { if let Some(osds) = response.as_array() {
let osd_list: Vec<CephOsd> = osds let osd_list: Vec<CephOsd> = osds
.iter() .iter()
.filter_map(|osd| { .filter_map(|osd| {
@ -179,7 +179,7 @@ pub async fn list_osds(
Ok(osd_list) Ok(osd_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }
@ -241,10 +241,10 @@ pub async fn list_mds(
.await .await
.map_err(|e| format!("Failed to list Ceph MDS: {}", e))?; .map_err(|e| format!("Failed to list Ceph MDS: {}", e))?;
if let Some(mds) = response.get("data").and_then(|d| d.as_array()) { if let Some(mds) = response.as_array() {
Ok(mds.to_vec()) Ok(mds.to_vec())
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }
@ -287,10 +287,10 @@ pub async fn list_rbd(
.await .await
.map_err(|e| format!("Failed to list RBD images in pool {}: {}", pool, e))?; .map_err(|e| format!("Failed to list RBD images in pool {}: {}", pool, e))?;
if let Some(images) = response.get("data").and_then(|d| d.as_array()) { if let Some(images) = response.as_array() {
Ok(images.to_vec()) Ok(images.to_vec())
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }
@ -415,7 +415,7 @@ pub async fn list_monitors(
.await .await
.map_err(|e| format!("Failed to list Ceph monitors: {}", e))?; .map_err(|e| format!("Failed to list Ceph monitors: {}", e))?;
if let Some(mons) = response.get("data").and_then(|d| d.as_array()) { if let Some(mons) = response.as_array() {
let mon_list: Vec<CephMonitor> = mons let mon_list: Vec<CephMonitor> = mons
.iter() .iter()
.filter_map(|mon| { .filter_map(|mon| {
@ -439,7 +439,7 @@ pub async fn list_monitors(
Ok(mon_list) Ok(mon_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }
@ -479,7 +479,7 @@ pub async fn get_ceph_health(
.await .await
.map_err(|e| format!("Failed to get Ceph health: {}", e))?; .map_err(|e| format!("Failed to get Ceph health: {}", e))?;
let health = response.get("data").ok_or("Invalid response format")?; let health = &response;
let details: Vec<String> = health let details: Vec<String> = health
.get("details") .get("details")

View File

@ -45,7 +45,7 @@ pub async fn list_ceph_clusters(
.await .await
.map_err(|e| format!("Failed to list Ceph clusters: {}", e))?; .map_err(|e| format!("Failed to list Ceph clusters: {}", e))?;
if let Some(clusters) = response.get("data").and_then(|d| d.as_array()) { if let Some(clusters) = response.as_array() {
let cluster_list: Vec<CephCluster> = clusters let cluster_list: Vec<CephCluster> = clusters
.iter() .iter()
.filter_map(|cluster| { .filter_map(|cluster| {
@ -150,7 +150,7 @@ pub async fn list_ceph_clusters(
Ok(cluster_list) Ok(cluster_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }
@ -166,7 +166,8 @@ pub async fn get_ceph_cluster_status(
.await .await
.map_err(|e| format!("Failed to get Ceph cluster {} status: {}", cluster_id, e))?; .map_err(|e| format!("Failed to get Ceph cluster {} status: {}", cluster_id, e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let id = data let id = data
.get("cluster_id") .get("cluster_id")
.and_then(|i| i.as_str()) .and_then(|i| i.as_str())
@ -195,8 +196,6 @@ pub async fn get_ceph_cluster_status(
osd_map, osd_map,
pg_map, pg_map,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }

View File

@ -45,7 +45,8 @@ pub async fn upload_certificate(
.await .await
.map_err(|e| format!("Failed to upload certificate: {}", e))?; .map_err(|e| format!("Failed to upload certificate: {}", e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let id = data let id = data
.get("id") .get("id")
.and_then(|i| i.as_str()) .and_then(|i| i.as_str())
@ -110,8 +111,6 @@ pub async fn upload_certificate(
signature_algorithm, signature_algorithm,
san, san,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }
@ -127,7 +126,8 @@ pub async fn get_certificate(
.await .await
.map_err(|e| format!("Failed to get certificate {}: {}", cert_id, e))?; .map_err(|e| format!("Failed to get certificate {}: {}", cert_id, e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let id = data let id = data
.get("id") .get("id")
.and_then(|i| i.as_str()) .and_then(|i| i.as_str())
@ -192,8 +192,6 @@ pub async fn get_certificate(
signature_algorithm, signature_algorithm,
san, san,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }
@ -208,7 +206,7 @@ pub async fn list_certificates(
.await .await
.map_err(|e| format!("Failed to list certificates: {}", e))?; .map_err(|e| format!("Failed to list certificates: {}", e))?;
if let Some(certs) = response.get("data").and_then(|d| d.as_array()) { if let Some(certs) = response.as_array() {
let cert_list: Vec<Certificate> = certs let cert_list: Vec<Certificate> = certs
.iter() .iter()
.filter_map(|cert| { .filter_map(|cert| {
@ -307,7 +305,7 @@ pub async fn list_node_certificates(
.await .await
.map_err(|e| format!("Failed to list node certificates for {}: {}", node, e))?; .map_err(|e| format!("Failed to list node certificates for {}: {}", node, e))?;
if let Some(certs) = response.get("data").and_then(|d| d.as_array()) { if let Some(certs) = response.as_array() {
let cert_list: Vec<Certificate> = certs let cert_list: Vec<Certificate> = certs
.iter() .iter()
.filter_map(|cert| { .filter_map(|cert| {
@ -401,7 +399,8 @@ pub async fn upload_node_certificate(
.await .await
.map_err(|e| format!("Failed to upload node certificate for {}: {}", node, e))?; .map_err(|e| format!("Failed to upload node certificate for {}: {}", node, e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let id = data let id = data
.get("id") .get("id")
.and_then(|i| i.as_str()) .and_then(|i| i.as_str())
@ -466,7 +465,5 @@ pub async fn upload_node_certificate(
signature_algorithm, signature_algorithm,
san, san,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }

View File

@ -12,16 +12,32 @@ pub struct ProxmoxClient {
username: String, username: String,
api_token: Option<String>, api_token: Option<String>,
pub ticket: Option<String>, pub ticket: Option<String>,
pub csrf_token: Option<String>,
client: Client, client: Client,
} }
/// Authentication response from Proxmox /// Outer envelope wrapping every Proxmox API response.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ProxmoxEnvelope<T> {
data: T,
}
/// Authentication response from Proxmox (inner `data` object).
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct AuthResponse { pub struct AuthResponse {
/// Cookie value — `PVEAuthCookie=<ticket>`.
pub ticket: String, pub ticket: String,
pub username: String, pub username: String,
/// Seconds since epoch when the ticket expires.
#[serde(default)]
pub expire: u64, pub expire: u64,
pub cap: String, /// Required on mutating requests as `CSRFPreventionToken` header.
#[serde(rename = "CSRFPreventionToken")]
pub csrf_prevention_token: Option<String>,
/// Capability map — structure varies, only needed for display/debug.
#[serde(default)]
pub cap: Option<serde_json::Value>,
} }
/// API token for authentication /// API token for authentication
@ -42,21 +58,28 @@ impl ProxmoxClient {
username: username.to_string(), username: username.to_string(),
api_token: None, api_token: None,
ticket: None, ticket: None,
csrf_token: None,
client: Client::builder() client: Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(30))
.build() .build()
.expect("Failed to create HTTP client"), .expect("Failed to create HTTP client"),
} }
} }
/// Set the ticket for authentication /// Set the ticket for cookie-based authentication.
pub fn set_ticket(&mut self, ticket: &str) { pub fn set_ticket(&mut self, ticket: &str) {
self.ticket = Some(ticket.to_string()); self.ticket = Some(ticket.to_string());
} }
/// Authenticate with root username and password /// Set the CSRF prevention token (required for mutating requests).
/// Returns the API ticket for subsequent requests pub fn set_csrf_token(&mut self, token: &str) {
pub async fn authenticate(&self, password: &str) -> Result<String> { self.csrf_token = Some(token.to_string());
}
/// Authenticate with username + password.
/// Stores the ticket and CSRF token on success; returns the ticket string.
pub async fn authenticate(&mut self, password: &str) -> Result<String> {
let url = format!( let url = format!(
"https://{}:{}/api2/json/access/ticket", "https://{}:{}/api2/json/access/ticket",
self.base_url, self.port self.base_url, self.port
@ -82,11 +105,17 @@ impl ProxmoxClient {
)); ));
} }
let auth: AuthResponse = response let envelope: ProxmoxEnvelope<AuthResponse> = response
.json() .json()
.await .await
.map_err(|e| anyhow!("Failed to parse authentication response: {}", e))?; .map_err(|e| anyhow!("Failed to parse authentication response: {}", e))?;
let auth = envelope.data;
self.ticket = Some(auth.ticket.clone());
if let Some(csrf) = auth.csrf_prevention_token {
self.csrf_token = Some(csrf);
}
Ok(auth.ticket) Ok(auth.ticket)
} }
@ -105,12 +134,12 @@ impl ProxmoxClient {
) )
} }
/// Build request headers with authentication /// Build request headers with authentication.
fn build_headers(&self, ticket: Option<&str>) -> reqwest::header::HeaderMap { /// `include_csrf` should be true for POST / PUT / DELETE requests.
fn build_headers(&self, ticket: Option<&str>, include_csrf: bool) -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new(); let mut headers = reqwest::header::HeaderMap::new();
if let Some(token) = &self.api_token { if let Some(token) = &self.api_token {
// API token format: user@realm!tokenid=tokenvalue
headers.insert( headers.insert(
reqwest::header::AUTHORIZATION, reqwest::header::AUTHORIZATION,
format!("PVEAPIAuth {}", token) format!("PVEAPIAuth {}", token)
@ -118,13 +147,20 @@ impl ProxmoxClient {
.expect("Invalid auth header"), .expect("Invalid auth header"),
); );
} else if let Some(ticket) = ticket { } else if let Some(ticket) = ticket {
// Cookie-based authentication
headers.insert( headers.insert(
"Cookie", "Cookie",
format!("PVEAuthCookie={}", ticket) format!("PVEAuthCookie={}", ticket)
.parse() .parse()
.expect("Invalid cookie header"), .expect("Invalid cookie header"),
); );
if include_csrf {
if let Some(csrf) = &self.csrf_token {
headers.insert(
"CSRFPreventionToken",
csrf.parse().expect("Invalid CSRF token header"),
);
}
}
} }
headers.insert( headers.insert(
@ -144,7 +180,7 @@ impl ProxmoxClient {
ticket: Option<&str>, ticket: Option<&str>,
) -> Result<T> { ) -> Result<T> {
let url = self.get_api_url(path); let url = self.get_api_url(path);
let headers = self.build_headers(ticket); let headers = self.build_headers(ticket, false);
let response = self let response = self
.client .client
@ -165,7 +201,7 @@ impl ProxmoxClient {
ticket: Option<&str>, ticket: Option<&str>,
) -> Result<T> { ) -> Result<T> {
let url = self.get_api_url(path); let url = self.get_api_url(path);
let headers = self.build_headers(ticket); let headers = self.build_headers(ticket, true);
let response = self let response = self
.client .client
@ -187,7 +223,7 @@ impl ProxmoxClient {
ticket: Option<&str>, ticket: Option<&str>,
) -> Result<T> { ) -> Result<T> {
let url = self.get_api_url(path); let url = self.get_api_url(path);
let headers = self.build_headers(ticket); let headers = self.build_headers(ticket, true);
let response = self let response = self
.client .client
@ -208,7 +244,7 @@ impl ProxmoxClient {
ticket: Option<&str>, ticket: Option<&str>,
) -> Result<T> { ) -> Result<T> {
let url = self.get_api_url(path); let url = self.get_api_url(path);
let headers = self.build_headers(ticket); let headers = self.build_headers(ticket, true);
let response = self let response = self
.client .client
@ -280,6 +316,8 @@ mod tests {
assert_eq!(client.base_url(), "pve.example.com"); assert_eq!(client.base_url(), "pve.example.com");
assert_eq!(client.port(), 8006); assert_eq!(client.port(), 8006);
assert_eq!(client.username(), "root@pam"); assert_eq!(client.username(), "root@pam");
assert!(client.ticket.is_none());
assert!(client.csrf_token.is_none());
} }
#[test] #[test]
@ -300,4 +338,73 @@ mod tests {
"https://pve.example.com:8006/api2/json/cluster/resources" "https://pve.example.com:8006/api2/json/cluster/resources"
); );
} }
#[test]
fn test_auth_response_envelope_deserialization() {
// Validates that the `{"data": {...}}` envelope Proxmox uses is parsed
// correctly into ProxmoxEnvelope<AuthResponse>.
let json = r#"{
"data": {
"Ticket": "PVE:root@pam:12345",
"Username": "root@pam",
"Expire": 1800,
"CSRFPreventionToken": "abc123",
"Cap": null
}
}"#;
let envelope: ProxmoxEnvelope<AuthResponse> =
serde_json::from_str(json).expect("envelope should parse");
assert_eq!(envelope.data.ticket, "PVE:root@pam:12345");
assert_eq!(
envelope.data.csrf_prevention_token.as_deref(),
Some("abc123")
);
}
#[test]
fn test_auth_response_envelope_no_csrf() {
// Some Proxmox versions or API tokens may omit CSRFPreventionToken.
let json = r#"{
"data": {
"Ticket": "PVE:root@pam:99999",
"Username": "root@pam"
}
}"#;
let envelope: ProxmoxEnvelope<AuthResponse> =
serde_json::from_str(json).expect("envelope should parse without CSRF");
assert_eq!(envelope.data.ticket, "PVE:root@pam:99999");
assert!(envelope.data.csrf_prevention_token.is_none());
}
#[test]
fn test_build_headers_get_omits_csrf() {
let mut client = ProxmoxClient::new("pve.example.com", 8006, "root@pam");
client.set_ticket("my-ticket");
client.set_csrf_token("my-csrf");
let headers = client.build_headers(Some("my-ticket"), false);
assert!(!headers.contains_key("CSRFPreventionToken"));
assert!(headers.contains_key("Cookie"));
}
#[test]
fn test_build_headers_post_includes_csrf() {
let mut client = ProxmoxClient::new("pve.example.com", 8006, "root@pam");
client.set_ticket("my-ticket");
client.set_csrf_token("my-csrf");
let headers = client.build_headers(Some("my-ticket"), true);
assert!(headers.contains_key("CSRFPreventionToken"));
let csrf_val = headers.get("CSRFPreventionToken").unwrap().to_str().unwrap();
assert_eq!(csrf_val, "my-csrf");
}
#[test]
fn test_set_ticket_and_csrf_token() {
let mut client = ProxmoxClient::new("pve.example.com", 8006, "root@pam");
client.set_ticket("ticket-value");
client.set_csrf_token("csrf-value");
assert_eq!(client.ticket.as_deref(), Some("ticket-value"));
assert_eq!(client.csrf_token.as_deref(), Some("csrf-value"));
}
} }

View File

@ -35,7 +35,7 @@ pub async fn list_firewall_rules(
.await .await
.map_err(|e| format!("Failed to list firewall rules: {}", e))?; .map_err(|e| format!("Failed to list firewall rules: {}", e))?;
if let Some(rules) = response.get("data").and_then(|d| d.as_array()) { if let Some(rules) = response.as_array() {
let rule_list: Vec<FirewallRule> = rules let rule_list: Vec<FirewallRule> = rules
.iter() .iter()
.filter_map(|rule| { .filter_map(|rule| {
@ -68,7 +68,7 @@ pub async fn list_firewall_rules(
Ok(rule_list) Ok(rule_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }
@ -191,14 +191,12 @@ pub async fn get_firewall_status(
.map_err(|e| format!("Failed to get firewall options: {}", e))?; .map_err(|e| format!("Failed to get firewall options: {}", e))?;
let enabled = options_response let enabled = options_response
.get("data") .get("enabled")
.and_then(|d| d.get("enabled"))
.and_then(|e| e.as_bool()) .and_then(|e| e.as_bool())
.unwrap_or(false); .unwrap_or(false);
let rules: Vec<FirewallRule> = rules_response let rules: Vec<FirewallRule> = rules_response
.get("data") .as_array()
.and_then(|d| d.as_array())
.unwrap_or(&Vec::new()) .unwrap_or(&Vec::new())
.iter() .iter()
.filter_map(|rule| { .filter_map(|rule| {
@ -264,10 +262,10 @@ pub async fn list_firewall_zones(
.await .await
.map_err(|e| format!("Failed to list firewall zones: {}", e))?; .map_err(|e| format!("Failed to list firewall zones: {}", e))?;
if let Some(zones) = response.get("data").and_then(|d| d.as_array()) { if let Some(zones) = response.as_array() {
Ok(zones.to_vec()) Ok(zones.to_vec())
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }

View File

@ -34,7 +34,7 @@ pub async fn list_ha_groups(
.await .await
.map_err(|e| format!("Failed to list HA groups: {}", e))?; .map_err(|e| format!("Failed to list HA groups: {}", e))?;
if let Some(groups) = response.get("data").and_then(|d| d.as_array()) { if let Some(groups) = response.as_array() {
let group_list: Vec<HaGroup> = groups let group_list: Vec<HaGroup> = groups
.iter() .iter()
.filter_map(|group| { .filter_map(|group| {
@ -68,7 +68,7 @@ pub async fn list_ha_groups(
Ok(group_list) Ok(group_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }
@ -144,7 +144,7 @@ pub async fn list_ha_resources(
.await .await
.map_err(|e| format!("Failed to list HA resources: {}", e))?; .map_err(|e| format!("Failed to list HA resources: {}", e))?;
if let Some(resources) = response.get("data").and_then(|d| d.as_array()) { if let Some(resources) = response.as_array() {
let resource_list: Vec<HaResource> = resources let resource_list: Vec<HaResource> = resources
.iter() .iter()
.filter_map(|resource| { .filter_map(|resource| {
@ -179,7 +179,7 @@ pub async fn list_ha_resources(
Ok(resource_list) Ok(resource_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }

View File

@ -36,7 +36,8 @@ pub async fn get_node_metrics(
.await .await
.map_err(|e| format!("Failed to get node metrics for {}: {}", node, e))?; .map_err(|e| format!("Failed to get node metrics for {}: {}", node, e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let cpu = data.get("cpu").and_then(|c| c.as_f64()).unwrap_or(0.0); let cpu = data.get("cpu").and_then(|c| c.as_f64()).unwrap_or(0.0);
let memory = data.get("memory").and_then(|m| m.as_f64()).unwrap_or(0.0); let memory = data.get("memory").and_then(|m| m.as_f64()).unwrap_or(0.0);
let disk = data.get("disk").and_then(|d| d.as_f64()).unwrap_or(0.0); let disk = data.get("disk").and_then(|d| d.as_f64()).unwrap_or(0.0);
@ -52,8 +53,6 @@ pub async fn get_node_metrics(
load, load,
uptime, uptime,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }
@ -68,7 +67,7 @@ pub async fn list_nodes(
.await .await
.map_err(|e| format!("Failed to list nodes: {}", e))?; .map_err(|e| format!("Failed to list nodes: {}", e))?;
if let Some(resources) = response.get("data").and_then(|d| d.as_array()) { if let Some(resources) = response.as_array() {
let node_list: Vec<NodeStatus> = resources let node_list: Vec<NodeStatus> = resources
.iter() .iter()
.filter_map(|resource| { .filter_map(|resource| {
@ -107,7 +106,7 @@ pub async fn list_nodes(
Ok(node_list) Ok(node_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Ok(vec![])
} }
} }

View File

@ -53,7 +53,8 @@ pub async fn migrate_vm(
.await .await
.map_err(|e| format!("Failed to migrate VM {}: {}", vm_id, e))?; .map_err(|e| format!("Failed to migrate VM {}: {}", vm_id, e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let task_id = data let task_id = data
.get("taskid") .get("taskid")
.and_then(|t| t.as_str()) .and_then(|t| t.as_str())
@ -80,8 +81,6 @@ pub async fn migrate_vm(
end_time: None, end_time: None,
error: None, error: None,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }
@ -97,7 +96,7 @@ pub async fn list_migration_status(
.await .await
.map_err(|e| format!("Failed to list migration tasks for node {}: {}", node, e))?; .map_err(|e| format!("Failed to list migration tasks for node {}: {}", node, e))?;
if let Some(tasks) = response.get("data").and_then(|d| d.as_array()) { if let Some(tasks) = response.as_array() {
let task_list: Vec<MigrationTask> = tasks let task_list: Vec<MigrationTask> = tasks
.iter() .iter()
.filter_map(|task| { .filter_map(|task| {
@ -145,7 +144,7 @@ pub async fn list_migration_status(
Ok(task_list) Ok(task_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Ok(vec![])
} }
} }
@ -162,7 +161,8 @@ pub async fn get_migration_task_status(
.await .await
.map_err(|e| format!("Failed to get migration task {}: {}", task_id, e))?; .map_err(|e| format!("Failed to get migration task {}: {}", task_id, e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let status = data let status = data
.get("status") .get("status")
.and_then(|s| s.as_str()) .and_then(|s| s.as_str())
@ -187,8 +187,6 @@ pub async fn get_migration_task_status(
bytes_remaining, bytes_remaining,
downtime, downtime,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }

View File

@ -34,7 +34,7 @@ pub async fn list_evpn_zones(
.await .await
.map_err(|e| format!("Failed to list EVPN zones: {}", e))?; .map_err(|e| format!("Failed to list EVPN zones: {}", e))?;
if let Some(zones) = response.get("data").and_then(|d| d.as_array()) { if let Some(zones) = response.as_array() {
let zone_list: Vec<EvpnZone> = zones let zone_list: Vec<EvpnZone> = zones
.iter() .iter()
.filter_map(|zone| { .filter_map(|zone| {
@ -68,7 +68,7 @@ pub async fn list_evpn_zones(
Ok(zone_list) Ok(zone_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }
@ -140,7 +140,7 @@ pub async fn list_vnets(
.await .await
.map_err(|e| format!("Failed to list virtual networks: {}", e))?; .map_err(|e| format!("Failed to list virtual networks: {}", e))?;
if let Some(vnets) = response.get("data").and_then(|d| d.as_array()) { if let Some(vnets) = response.as_array() {
let vnet_list: Vec<VirtualNetwork> = vnets let vnet_list: Vec<VirtualNetwork> = vnets
.iter() .iter()
.filter_map(|vnet| { .filter_map(|vnet| {
@ -166,7 +166,7 @@ pub async fn list_vnets(
Ok(vnet_list) Ok(vnet_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }
@ -252,10 +252,10 @@ pub async fn list_dhcp_leases(
.await .await
.map_err(|e| format!("Failed to list DHCP leases for vnet {}: {}", vnet, e))?; .map_err(|e| format!("Failed to list DHCP leases for vnet {}: {}", vnet, e))?;
if let Some(leases) = response.get("data").and_then(|d| d.as_array()) { if let Some(leases) = response.as_array() {
Ok(leases.to_vec()) Ok(leases.to_vec())
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Err("Invalid response format".to_string())
} }
} }

View File

@ -24,7 +24,8 @@ pub async fn get_shell_ticket(
.await .await
.map_err(|e| format!("Failed to get shell ticket for remote {}: {}", remote, e))?; .map_err(|e| format!("Failed to get shell ticket for remote {}: {}", remote, e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let ticket_value = data let ticket_value = data
.get("ticket") .get("ticket")
.and_then(|t| t.as_str()) .and_then(|t| t.as_str())
@ -53,8 +54,6 @@ pub async fn get_shell_ticket(
expires, expires,
permissions, permissions,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }
@ -69,7 +68,7 @@ pub async fn validate_shell_ticket(
.await .await
.map_err(|e| format!("Failed to validate shell ticket: {}", e))?; .map_err(|e| format!("Failed to validate shell ticket: {}", e))?;
Ok(response.get("data").is_some()) Ok(!response.is_null())
} }
/// Get shell WebSocket URL /// Get shell WebSocket URL

View File

@ -38,7 +38,7 @@ pub async fn list_tasks(
.await .await
.map_err(|e| format!("Failed to list tasks for node {}: {}", node, e))?; .map_err(|e| format!("Failed to list tasks for node {}: {}", node, e))?;
if let Some(tasks) = response.get("data").and_then(|d| d.as_array()) { if let Some(tasks) = response.as_array() {
let task_list: Vec<TaskInfo> = tasks let task_list: Vec<TaskInfo> = tasks
.iter() .iter()
.filter_map(|task| { .filter_map(|task| {
@ -97,7 +97,7 @@ pub async fn list_tasks(
Ok(task_list) Ok(task_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Ok(vec![])
} }
} }
@ -114,7 +114,8 @@ pub async fn get_task_status(
.await .await
.map_err(|e| format!("Failed to get task {}: {}", task_id, e))?; .map_err(|e| format!("Failed to get task {}: {}", task_id, e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let id = data let id = data
.get("id") .get("id")
.and_then(|i| i.as_str()) .and_then(|i| i.as_str())
@ -169,8 +170,6 @@ pub async fn get_task_status(
exit_status, exit_status,
description, description,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }
@ -206,7 +205,7 @@ pub async fn get_task_log(
.await .await
.map_err(|e| format!("Failed to get task log for {}: {}", task_id, e))?; .map_err(|e| format!("Failed to get task log for {}: {}", task_id, e))?;
if let Some(log_entries) = response.get("data").and_then(|d| d.as_array()) { if let Some(log_entries) = response.as_array() {
let log_list: Vec<TaskLogEntry> = log_entries let log_list: Vec<TaskLogEntry> = log_entries
.iter() .iter()
.map(|entry| { .map(|entry| {
@ -236,7 +235,7 @@ pub async fn get_task_log(
Ok(log_list) Ok(log_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Ok(vec![])
} }
} }
@ -258,7 +257,8 @@ pub async fn forward_task(
.await .await
.map_err(|e| format!("Failed to forward task {}: {}", task_id, e))?; .map_err(|e| format!("Failed to forward task {}: {}", task_id, e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let id = data let id = data
.get("id") .get("id")
.and_then(|i| i.as_str()) .and_then(|i| i.as_str())
@ -283,7 +283,5 @@ pub async fn forward_task(
exit_status: None, exit_status: None,
description: format!("Forwarded to {}", target_node), description: format!("Forwarded to {}", target_node),
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }

View File

@ -34,9 +34,9 @@ pub async fn check_updates(
let checked_at = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); let checked_at = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let updates: Vec<UpdateInfo> = response let updates: Vec<UpdateInfo> = response
.get("data") .as_array()
.and_then(|d| d.as_array()) .map(|arr| arr.as_slice())
.unwrap_or(&Vec::new()) .unwrap_or(&[])
.iter() .iter()
.filter_map(|update| { .filter_map(|update| {
let package = update.get("package")?.as_str()?.to_string(); let package = update.get("package")?.as_str()?.to_string();
@ -74,8 +74,7 @@ pub async fn list_updates(
.map_err(|e| format!("Failed to list updates: {}", e))?; .map_err(|e| format!("Failed to list updates: {}", e))?;
let updates: Vec<UpdateInfo> = response let updates: Vec<UpdateInfo> = response
.get("data") .as_array()
.and_then(|d| d.as_array())
.map(|arr| { .map(|arr| {
arr.iter() arr.iter()
.filter_map(|update| { .filter_map(|update| {
@ -153,11 +152,10 @@ pub async fn get_update_history(
.await .await
.map_err(|e| format!("Failed to get update history: {}", e))?; .map_err(|e| format!("Failed to get update history: {}", e))?;
if let Some(history) = response.get("data").and_then(|d| d.as_array()) { response
Ok(history.to_vec()) .as_array()
} else { .map(|arr| arr.to_vec())
Err("Invalid response format: missing 'data' field".to_string()) .ok_or_else(|| "Invalid response format: expected array".to_string())
}
} }
#[cfg(test)] #[cfg(test)]

View File

@ -33,8 +33,7 @@ pub async fn list_updates_all_remotes(
.map_err(|e| format!("Failed to list updates from all remotes: {}", e))?; .map_err(|e| format!("Failed to list updates from all remotes: {}", e))?;
let updates: Vec<RemoteUpdateInfo> = response let updates: Vec<RemoteUpdateInfo> = response
.get("data") .as_array()
.and_then(|d| d.as_array())
.map(|arr| { .map(|arr| {
arr.iter() arr.iter()
.filter_map(|update| { .filter_map(|update| {
@ -122,14 +121,10 @@ pub async fn list_pve_remotes(
.await .await
.map_err(|e| format!("Failed to list PVE remotes: {}", e))?; .map_err(|e| format!("Failed to list PVE remotes: {}", e))?;
if let Some(data) = response.get("data") { if let Some(arr) = response.as_array() {
if let Some(arr) = data.as_array() {
Ok(arr.to_vec()) Ok(arr.to_vec())
} else { } else {
Ok(vec![data.clone()]) Ok(vec![response])
}
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }
@ -146,8 +141,7 @@ pub async fn check_remote_updates(
.map_err(|e| format!("Failed to check updates for remote {}: {}", remote, e))?; .map_err(|e| format!("Failed to check updates for remote {}: {}", remote, e))?;
let updates: Vec<RemoteUpdateInfo> = response let updates: Vec<RemoteUpdateInfo> = response
.get("data") .as_array()
.and_then(|d| d.as_array())
.map(|arr| { .map(|arr| {
arr.iter() arr.iter()
.filter_map(|update| { .filter_map(|update| {

View File

@ -46,7 +46,7 @@ pub async fn list_views(
.await .await
.map_err(|e| format!("Failed to list dashboard views: {}", e))?; .map_err(|e| format!("Failed to list dashboard views: {}", e))?;
if let Some(views) = response.get("data").and_then(|d| d.as_array()) { if let Some(views) = response.as_array() {
let view_list: Vec<DashboardView> = views let view_list: Vec<DashboardView> = views
.iter() .iter()
.filter_map(|view| { .filter_map(|view| {
@ -143,7 +143,7 @@ pub async fn list_views(
Ok(view_list) Ok(view_list)
} else { } else {
Err("Invalid response format: missing 'data' field".to_string()) Ok(vec![])
} }
} }
@ -245,7 +245,8 @@ pub async fn get_view(
.await .await
.map_err(|e| format!("Failed to get dashboard view {}: {}", view_id, e))?; .map_err(|e| format!("Failed to get dashboard view {}: {}", view_id, e))?;
if let Some(data) = response.get("data") { {
let data = &response;
let id = data let id = data
.get("id") .get("id")
.and_then(|i| i.as_str()) .and_then(|i| i.as_str())
@ -342,7 +343,5 @@ pub async fn get_view(
created_at, created_at,
updated_at, updated_at,
}) })
} else {
Err("Invalid response format: missing 'data' field".to_string())
} }
} }

View File

@ -131,27 +131,30 @@ pub async fn list_vms(
client: &crate::proxmox::client::ProxmoxClient, client: &crate::proxmox::client::ProxmoxClient,
ticket: &str, ticket: &str,
) -> Result<Vec<VmInfo>, String> { ) -> Result<Vec<VmInfo>, String> {
let path = "cluster/resources"; // cluster/resources is GET-only; handle_response strips the {"data":[...]} envelope.
let params = serde_json::json!({
"type": "qemu"
});
let response: serde_json::Value = client let response: serde_json::Value = client
.post(path, &params, Some(ticket)) .get("cluster/resources?type=vm", Some(ticket))
.await .await
.map_err(|e| format!("Failed to list VMs: {}", e))?; .map_err(|e| format!("Failed to list VMs: {}", e))?;
// Parse the response to extract VM info let resources = response
// The API returns a list of resources in the "data" field .as_array()
if let Some(resources) = response.get("data").and_then(|d| d.as_array()) { .ok_or_else(|| "Invalid response format".to_string())?;
let vms: Vec<VmInfo> = resources let vms: Vec<VmInfo> = resources
.iter() .iter()
.filter_map(|r| { .filter_map(|r| {
let vmid = r.get("vmid")?.as_u64()?; let vmid = r.get("vmid")?.as_u64()?;
let node = r.get("node")?.as_str()?.to_string(); let node = r.get("node")?.as_str()?.to_string();
let name = r.get("name")?.as_str().map(|s| s.to_string()); // Only include qemu VMs (not LXC containers which also appear in cluster/resources?type=vm)
let status = r.get("status")?.as_str()?.to_string(); let resource_type = r.get("type").and_then(|t| t.as_str()).unwrap_or("");
let cpu = r.get("cpu")?.as_f64()?; if resource_type != "qemu" {
return None;
}
let name = r.get("name").and_then(|n| n.as_str()).map(|s| s.to_string());
let status = r.get("status").and_then(|s| s.as_str()).unwrap_or("unknown").to_string();
// cpu may be absent for stopped VMs
let cpu = r.get("cpu").and_then(|c| c.as_f64()).unwrap_or(0.0);
Some(VmInfo { Some(VmInfo {
id: vmid as u32, id: vmid as u32,
@ -163,10 +166,7 @@ pub async fn list_vms(
uptime: r.get("uptime").and_then(|u| u.as_u64()).unwrap_or(0), uptime: r.get("uptime").and_then(|u| u.as_u64()).unwrap_or(0),
node, node,
template: r.get("template").and_then(|t| t.as_bool()), template: r.get("template").and_then(|t| t.as_bool()),
agent: r agent: r.get("agent").and_then(|a| a.as_str()).map(|s| s.to_string()),
.get("agent")
.and_then(|a| a.as_str())
.map(|s| s.to_string()),
mem: r.get("mem").and_then(|m| m.as_u64()), mem: r.get("mem").and_then(|m| m.as_u64()),
max_mem: r.get("maxmem").and_then(|m| m.as_u64()), max_mem: r.get("maxmem").and_then(|m| m.as_u64()),
max_disk: r.get("maxdisk").and_then(|d| d.as_u64()), max_disk: r.get("maxdisk").and_then(|d| d.as_u64()),
@ -179,9 +179,6 @@ pub async fn list_vms(
.collect(); .collect();
Ok(vms) Ok(vms)
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
} }
/// Get VM details /// Get VM details
@ -197,8 +194,7 @@ pub async fn get_vm(
.await .await
.map_err(|e| format!("Failed to get VM {}: {}", vmid, e))?; .map_err(|e| format!("Failed to get VM {}: {}", vmid, e))?;
// Parse the response to extract VM info let vm = &response;
let vm = response.get("data").ok_or("Invalid response format")?;
Ok(VmInfo { Ok(VmInfo {
id: vmid, id: vmid,
@ -415,11 +411,10 @@ pub async fn list_snapshots(
.await .await
.map_err(|e| format!("Failed to list snapshots for VM {}: {}", vmid, e))?; .map_err(|e| format!("Failed to list snapshots for VM {}: {}", vmid, e))?;
if let Some(snapshots) = response.get("data").and_then(|d| d.as_array()) { response
Ok(snapshots.to_vec()) .as_array()
} else { .map(|arr| arr.to_vec())
Err("Invalid response format: missing 'data' field".to_string()) .ok_or_else(|| "Invalid response format".to_string())
}
} }
#[cfg(test)] #[cfg(test)]

View File

@ -1,8 +1,8 @@
import React from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index'; import { Button } from '@/components/ui/index';
import { MoreHorizontal } from 'lucide-react'; import { MoreHorizontal, Plug, PlugZap } from 'lucide-react';
interface RemoteInfo { interface RemoteInfo {
id: string; id: string;
@ -25,6 +25,77 @@ interface RemotesListProps {
onDisconnect?: (remote: RemoteInfo) => void; onDisconnect?: (remote: RemoteInfo) => void;
} }
function ActionsMenu({
remote,
onEdit,
onDelete,
onConnect,
onDisconnect,
}: {
remote: RemoteInfo;
onEdit?: (remote: RemoteInfo) => void;
onDelete?: (remote: RemoteInfo) => void;
onConnect?: (remote: RemoteInfo) => void;
onDisconnect?: (remote: RemoteInfo) => void;
}) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative" ref={menuRef}>
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => setOpen((v) => !v)}
title="More actions"
>
<MoreHorizontal className="h-4 w-4" />
</button>
{open && (
<div className="absolute right-0 z-50 mt-1 w-44 rounded-md border bg-background shadow-lg">
<div className="py-1">
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-accent"
onClick={() => { setOpen(false); onEdit?.(remote); }}
>
Edit
</button>
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-accent"
onClick={() => {
setOpen(false);
if (remote.status === 'connected') {
onDisconnect?.(remote);
} else {
onConnect?.(remote);
}
}}
>
Test Connection
</button>
<div className="my-1 h-px bg-border" />
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-destructive/10"
onClick={() => { setOpen(false); onDelete?.(remote); }}
>
Delete
</button>
</div>
</div>
)}
</div>
);
}
export function RemotesList({ export function RemotesList({
remotes, remotes,
onRefresh, onRefresh,
@ -100,44 +171,31 @@ export function RemotesList({
</TableCell> </TableCell>
<TableCell>{remote.lastConnected || '-'}</TableCell> <TableCell>{remote.lastConnected || '-'}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-1">
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => onEdit?.(remote)}
title="Edit"
>
<span className="h-4 w-4 text-xs"></span>
</button>
{remote.status === 'connected' ? ( {remote.status === 'connected' ? (
<button <button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600" className="rounded-md p-1 hover:bg-red-100 hover:text-red-600 text-green-600"
onClick={() => onDisconnect?.(remote)} onClick={() => onDisconnect?.(remote)}
title="Disconnect" title="Disconnect"
> >
<span className="h-4 w-4 text-xs">🔌</span> <PlugZap className="h-4 w-4" />
</button> </button>
) : ( ) : (
<button <button
className="rounded-md p-1 hover:bg-green-100 hover:text-green-600" className="rounded-md p-1 hover:bg-green-100 hover:text-green-600 text-muted-foreground"
onClick={() => onConnect?.(remote)} onClick={() => onConnect?.(remote)}
title="Connect" title="Test connection"
> >
<span className="h-4 w-4 text-xs">🔌</span> <Plug className="h-4 w-4" />
</button> </button>
)} )}
<button <ActionsMenu
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600" remote={remote}
onClick={() => onDelete?.(remote)} onEdit={onEdit}
title="Delete" onDelete={onDelete}
> onConnect={onConnect}
<span className="h-4 w-4 text-xs">🗑</span> onDisconnect={onDisconnect}
</button> />
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@ -40,6 +40,53 @@ export async function removeProxmoxCluster(id: string): Promise<void> {
await invoke("remove_proxmox_cluster", { id }); await invoke("remove_proxmox_cluster", { id });
} }
/**
* Update an existing Proxmox cluster's metadata and credentials atomically.
* Uses a single SQL UPDATE so there is no window where the record is missing.
*/
export async function updateProxmoxCluster(
id: string,
name: string,
clusterType: ClusterType,
connection: { url: string; port: number },
username: string,
password: string
): Promise<ClusterInfo> {
return await invoke<ClusterInfo>("update_proxmox_cluster", {
id,
name,
clusterType,
connection,
username,
password,
});
}
/**
* Ping a Proxmox cluster authenticates and calls the version endpoint to verify
* the API is reachable and credentials are valid.
*/
export async function pingProxmoxCluster(clusterId: string): Promise<unknown> {
return await invoke("ping_proxmox_cluster", { clusterId });
}
/**
* Connect (or re-connect) to a cluster stored in the DB.
* Authenticates against the Proxmox API and populates the in-memory pool.
* Use after app restart or after an explicit disconnect.
*/
export async function connectProxmoxCluster(clusterId: string): Promise<boolean> {
return await invoke<boolean>("connect_proxmox_cluster", { clusterId });
}
/**
* Disconnect from a cluster by removing its authenticated session from the
* in-memory pool. Credentials are retained in the DB for later reconnection.
*/
export async function disconnectProxmoxCluster(clusterId: string): Promise<void> {
await invoke("disconnect_proxmox_cluster", { clusterId });
}
/** /**
* List all Proxmox clusters * List all Proxmox clusters
*/ */

View File

@ -1,10 +1,111 @@
import React from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Button } from '@/components/ui/index'; import { Button } from '@/components/ui/index';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { PoolList, OSDList, CephHealthWidget, MonitorList } from '@/components/Proxmox'; import { PoolList, OSDList, CephHealthWidget, MonitorList } from '@/components/Proxmox';
import { listProxmoxClusters, listCephPools, listCephOsd, getCephHealth } from '@/lib/proxmoxClient';
import { toast } from 'sonner';
export function ProxmoxCephPage() { export function ProxmoxCephPage() {
const [clusterId, setClusterId] = useState<string>('');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [health, setHealth] = useState<any>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [pools, setPools] = useState<any[]>([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [osds, setOsds] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isCephEnabled, setIsCephEnabled] = useState<boolean | null>(null);
const loadData = useCallback(async (cId: string) => {
if (!cId) return;
setLoading(true);
setError(null);
// Check Ceph availability by fetching health first
let cephAvailable = false;
try {
const h = await getCephHealth(cId);
setHealth(h);
cephAvailable = true;
} catch {
setIsCephEnabled(false);
setLoading(false);
return;
}
if (cephAvailable) {
setIsCephEnabled(true);
const [poolsResult, osdsResult] = await Promise.allSettled([
listCephPools(cId),
listCephOsd(cId),
]);
if (poolsResult.status === 'fulfilled') {
setPools(poolsResult.value);
} else {
toast.error('Failed to load Ceph pools');
}
if (osdsResult.status === 'fulfilled') {
setOsds(osdsResult.value);
} else {
toast.error('Failed to load Ceph OSDs');
}
}
setLoading(false);
}, []);
useEffect(() => {
listProxmoxClusters()
.then((cls) => {
if (cls.length > 0) {
setClusterId(cls[0].id);
loadData(cls[0].id);
} else {
setIsCephEnabled(false);
}
})
.catch((err) => {
console.error('Failed to load clusters:', err);
setError('Failed to load clusters');
setIsCephEnabled(false);
});
}, [loadData]);
const handleRefresh = () => {
if (clusterId) loadData(clusterId);
};
if (isCephEnabled === false) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Ceph Storage</h1>
<p className="text-muted-foreground">Manage Ceph clusters and storage</p>
</div>
</div>
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{error ? (
<p>{error}</p>
) : (
<>
<p className="text-base font-medium">Ceph is not configured on this cluster</p>
<p className="text-sm mt-1">
Ceph storage requires a dedicated Ceph cluster deployment on the Proxmox nodes.
</p>
</>
)}
</CardContent>
</Card>
</div>
);
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -13,8 +114,8 @@ export function ProxmoxCephPage() {
<p className="text-muted-foreground">Manage Ceph clusters and storage</p> <p className="text-muted-foreground">Manage Ceph clusters and storage</p>
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button variant="outline" size="sm"> <Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh Refresh
</Button> </Button>
</div> </div>
@ -26,9 +127,11 @@ export function ProxmoxCephPage() {
<CardTitle>Ceph Health</CardTitle> <CardTitle>Ceph Health</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<CephHealthWidget {health ? (
health={{ status: 'HEALTH_OK', summary: 'Cluster healthy', details: [] }} <CephHealthWidget health={health} />
/> ) : (
<p className="text-sm text-muted-foreground">Loading health data...</p>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -40,8 +143,8 @@ export function ProxmoxCephPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<PoolList <PoolList
pools={[]} pools={pools}
onRefresh={() => {}} onRefresh={handleRefresh}
/> />
</CardContent> </CardContent>
</Card> </Card>
@ -52,8 +155,8 @@ export function ProxmoxCephPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<OSDList <OSDList
osds={[]} osds={osds}
onRefresh={() => {}} onRefresh={handleRefresh}
/> />
</CardContent> </CardContent>
</Card> </Card>
@ -66,7 +169,7 @@ export function ProxmoxCephPage() {
<CardContent> <CardContent>
<MonitorList <MonitorList
monitors={[]} monitors={[]}
onRefresh={() => {}} onRefresh={handleRefresh}
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

@ -6,7 +6,7 @@ import { AddRemoteForm } from '@/components/Proxmox';
import { EditRemoteForm } from '@/components/Proxmox'; import { EditRemoteForm } from '@/components/Proxmox';
import { RemoveRemoteDialog } from '@/components/Proxmox'; import { RemoveRemoteDialog } from '@/components/Proxmox';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/index'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/index';
import { listProxmoxClusters, addProxmoxCluster, removeProxmoxCluster } from '@/lib/proxmoxClient'; import { listProxmoxClusters, addProxmoxCluster, removeProxmoxCluster, updateProxmoxCluster, connectProxmoxCluster, disconnectProxmoxCluster } from '@/lib/proxmoxClient';
import { ClusterType } from '@/lib/domain'; import { ClusterType } from '@/lib/domain';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -35,7 +35,7 @@ export function ProxmoxRemotesPage() {
url: c.url, url: c.url,
username: c.username, username: c.username,
type: c.clusterType === 've' ? 'pve' : 'pbs', type: c.clusterType === 've' ? 'pve' : 'pbs',
status: 'connected' as const, // Placeholder - actual status requires connection test status: (c.connected ? 'connected' : 'disconnected') as RemoteInfo['status'],
})); }));
setRemotes(remotesList); setRemotes(remotesList);
} catch (err) { } catch (err) {
@ -102,11 +102,7 @@ export function ProxmoxRemotesPage() {
const clusterType = config.type === 'pve' ? 've' : 'pbs'; const clusterType = config.type === 'pve' ? 've' : 'pbs';
const { hostname, port } = parseRemoteUrl(config.url, config.type); const { hostname, port } = parseRemoteUrl(config.url, config.type);
// Edit operation requires remove-then-add since backend doesn't support update. await updateProxmoxCluster(
// If add fails after remove, the remote will be lost - this is a known limitation
// until backend supports atomic update operations.
await removeProxmoxCluster(config.id);
await addProxmoxCluster(
config.id, config.id,
config.name, config.name,
clusterType as ClusterType, clusterType as ClusterType,
@ -136,6 +132,36 @@ export function ProxmoxRemotesPage() {
} }
}; };
const handleConnectRemote = async (remote: RemoteInfo) => {
try {
toast.info(`Connecting to ${remote.name}...`);
await connectProxmoxCluster(remote.id);
toast.success(`Connected to ${remote.name}`);
setRemotes((prev) =>
prev.map((r) => (r.id === remote.id ? { ...r, status: 'connected' } : r))
);
} catch (err) {
console.error('Failed to connect remote:', err);
toast.error('Connection failed: ' + String(err));
setRemotes((prev) =>
prev.map((r) => (r.id === remote.id ? { ...r, status: 'error' } : r))
);
}
};
const handleDisconnectRemote = async (remote: RemoteInfo) => {
try {
await disconnectProxmoxCluster(remote.id);
setRemotes((prev) =>
prev.map((r) => (r.id === remote.id ? { ...r, status: 'disconnected' } : r))
);
toast.info(`Disconnected from ${remote.name}`);
} catch (err) {
console.error('Failed to disconnect remote:', err);
toast.error('Disconnect failed: ' + String(err));
}
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -164,6 +190,8 @@ export function ProxmoxRemotesPage() {
onDelete={(remote) => { onDelete={(remote) => {
setRemovingRemote(remote as RemoteInfo | null); setRemovingRemote(remote as RemoteInfo | null);
}} }}
onConnect={(remote) => { void handleConnectRemote(remote as RemoteInfo); }}
onDisconnect={(remote) => { void handleDisconnectRemote(remote as RemoteInfo); }}
/> />
{showAddDialog && ( {showAddDialog && (