Compare commits
6 Commits
afe9ac1a3a
...
4d066e47fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d066e47fd | ||
|
|
9687f97d7c | ||
|
|
b091602741 | ||
| cb770661d7 | |||
|
|
68439bcd64 | ||
|
|
c5b97f8648 |
90
TICKET-proxmox-reconnect.md
Normal file
90
TICKET-proxmox-reconnect.md
Normal 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
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
@ -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,
|
||||||
|
|||||||
@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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![])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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![])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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![])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)]
|
||||||
|
|||||||
@ -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| {
|
||||||
|
|||||||
@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, ¶ms, 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)]
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user