Merge pull request 'fix/proxmox-reconnect-v1.2.3' (#125) from fix/proxmox-reconnect-v1.2.3 into beta
All checks were successful
Release Beta / autotag (push) Successful in 9s
Release Beta / changelog (push) Successful in 1m35s
Test / frontend-tests (push) Successful in 1m48s
Test / frontend-typecheck (push) Successful in 1m58s
Release Beta / build-macos-arm64 (push) Successful in 8m55s
Release Beta / build-linux-amd64 (push) Successful in 11m41s
Release Beta / build-windows-amd64 (push) Successful in 11m47s
Release Beta / build-linux-arm64 (push) Successful in 13m14s
Test / rust-fmt-check (push) Successful in 18m44s
Test / rust-clippy (push) Successful in 20m2s
Test / rust-tests (push) Successful in 22m11s

Reviewed-on: #125
This commit is contained in:
sarman 2026-06-21 01:33:24 +00:00
commit 3edb00dfb0
26 changed files with 817 additions and 435 deletions

View File

@ -31,7 +31,7 @@ jobs:
git fetch --depth=1 origin ${{ github.head_ref }}
git checkout FETCH_HEAD
- name: Build review context
- name: Build review batches
id: context
shell: bash
run: |
@ -49,13 +49,10 @@ jobs:
if [ "$FILE_COUNT" -eq 0 ]; then
echo "No reviewable files changed."
echo "diff_size=0" >> $GITHUB_OUTPUT
echo "batch_count=0" >> $GITHUB_OUTPUT
exit 0
fi
# Build context: full file content for each changed file.
# Files <= 500 lines: include complete content.
# Files > 500 lines: include the per-file diff with generous context (±50 lines).
#
# Secret scrubbing: match actual credential VALUES only — known API key formats,
# or keyword="long_quoted_literal" (25+ chars). Never scrub on keyword alone,
# which would silently delete function signatures, variable declarations, and tests.
@ -63,35 +60,53 @@ jobs:
# Only strip lines that are ENTIRELY a long base64 blob (e.g. PEM cert bodies)
B64_PATTERN='^[[:space:]]*[A-Za-z0-9+/]{60,}={0,2}[[:space:]]*$'
> /tmp/pr_context.txt
# Split changed files into batches capped at MAX_BATCH_LINES each.
# File boundaries are always respected — a file is never split across batches.
MAX_BATCH_LINES=2500
BATCH=1
BATCH_LINES=0
TOTAL_LINES=0
BATCH_FILE="/tmp/pr_batch_001.txt"
> "$BATCH_FILE"
while IFS= read -r file; do
[ -f "$file" ] || continue
lines=$(wc -l < "$file" | tr -d ' ')
printf '\n════════ FILE: %s (%s lines) ════════\n' "$file" "$lines" >> /tmp/pr_context.txt
if [ "$lines" -le 500 ]; then
# Full file — model sees the complete implementation
grep -v -E "$SECRET_PATTERN" "$file" \
| grep -v -E "$B64_PATTERN" \
>> /tmp/pr_context.txt || true
else
# Large file — emit annotated diff hunks (±50 lines of context each)
printf '[File too large for full view (%s lines) — showing changed sections only]\n' "$lines" >> /tmp/pr_context.txt
git diff -U50 origin/${{ github.base_ref }}..HEAD -- "$file" \
| grep -v -E "$SECRET_PATTERN" \
| grep -v -E "$B64_PATTERN" \
>> /tmp/pr_context.txt || true
file_lines=$(wc -l < "$file" | tr -d ' ')
# Build context for this individual file into a temp file
{
printf '\n════════ FILE: %s (%s lines) ════════\n' "$file" "$file_lines"
if [ "$file_lines" -le 500 ]; then
grep -v -E "$SECRET_PATTERN" "$file" | grep -v -E "$B64_PATTERN" || true
else
printf '[File too large for full view (%s lines) — showing changed sections only]\n' "$file_lines"
git diff -U50 origin/${{ github.base_ref }}..HEAD -- "$file" \
| grep -v -E "$SECRET_PATTERN" \
| grep -v -E "$B64_PATTERN" \
|| true
fi
} > /tmp/fc_tmp.txt
FC_LINES=$(wc -l < /tmp/fc_tmp.txt | tr -d ' ')
# Start a new batch if this file would overflow the current one (and batch is not empty)
if [ "$BATCH_LINES" -gt 0 ] && [ $((BATCH_LINES + FC_LINES)) -gt $MAX_BATCH_LINES ]; then
BATCH=$((BATCH + 1))
BATCH_LINES=0
BATCH_FILE="/tmp/pr_batch_$(printf '%03d' $BATCH).txt"
> "$BATCH_FILE"
fi
cat /tmp/fc_tmp.txt >> "$BATCH_FILE"
BATCH_LINES=$((BATCH_LINES + FC_LINES))
TOTAL_LINES=$((TOTAL_LINES + FC_LINES))
done < /tmp/pr_files.txt
TOTAL=$(wc -l < /tmp/pr_context.txt | tr -d ' ')
echo "diff_size=${TOTAL}" >> $GITHUB_OUTPUT
rm -f /tmp/fc_tmp.txt
# Cap at 6000 lines so we stay within the model's context window
if [ "$TOTAL" -gt 6000 ]; then
head -n 6000 /tmp/pr_context.txt > /tmp/pr_context_capped.txt
mv /tmp/pr_context_capped.txt /tmp/pr_context.txt
echo "[CONTEXT TRUNCATED at 6000 lines — ${TOTAL} total]" >> /tmp/pr_context.txt
fi
echo "batch_count=${BATCH}" >> $GITHUB_OUTPUT
echo "diff_size=${TOTAL_LINES}" >> $GITHUB_OUTPUT
echo "Built ${BATCH} batch(es) from ${FILE_COUNT} files (${TOTAL_LINES} total lines)"
- name: Build codebase index
id: index
@ -175,7 +190,7 @@ jobs:
echo "comment_lines=${LINES}" >> $GITHUB_OUTPUT
echo "Fetched PR history: ${LINES} lines"
- name: Analyze with LLM
- name: Analyze iteratively
id: analyze
if: steps.context.outputs.diff_size != '0'
shell: bash
@ -185,137 +200,197 @@ jobs:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_BODY: ${{ github.event.pull_request.body }}
BATCH_COUNT: ${{ steps.context.outputs.batch_count }}
run: |
set -euo pipefail
CHANGED_FILES=$(tr '\n' ' ' < /tmp/pr_files.txt)
> /tmp/pr_all_findings.txt
OVERALL_VERDICT="APPROVE"
BATCH_SUCCESS=0
BATCH_FAILED=0
# Build prompt file following anthropics/claude-code code-review pattern:
# - Multi-agent review (parallel analysis)
# - High-signal issues only (no nitpicks, style, or speculative concerns)
# - Validate findings against codebase
# - Consider PR title/description for author intent
# - Check for pre-existing issues
{
printf '%s\n\n' 'You are a senior engineer performing a code review following the anthropics/claude-code code-review pattern.'
printf 'PR Title: %s\n' "$PR_TITLE"
printf 'PR Body: %s\n\n' "${PR_BODY:-No description provided}"
printf 'Files changed: %s\n\n' "$CHANGED_FILES"
printf '%s\n' '---'
printf '%s\n\n' '## CODEBASE INDEX'
printf '%s\n' 'These are the ONLY Tauri commands, TypeScript exports, Rust public functions,'
printf '%s\n' 'and database tables that exist in this project. Before raising any finding,'
printf '%s\n' 'confirm that every symbol you cite appears in this list or in the file'
printf '%s\n' 'contents below. If it does not appear in either, your finding is fabricated.'
printf '%s\n' '---'
cat /tmp/codebase_index.txt
printf '%s\n\n' '---'
printf '%s\n\n' '## CHANGED FILE CONTENTS'
printf '%s\n' 'Each section is the COMPLETE, FINAL file after PR changes (not a diff).'
printf '%s\n\n' 'Files over 500 lines show only changed sections with surrounding context.'
printf '%s\n' '---'
cat /tmp/pr_context.txt
printf '%s\n\n' '---'
if [ -s /tmp/pr_comments.txt ]; then
cat /tmp/pr_comments.txt
printf '%s\n\n' '---'
printf '%s\n' '## CRITICAL: PRIOR REVIEW CONTEXT ABOVE'
printf '%s\n' 'Before raising ANY finding, check the review history above.'
printf '%s\n' 'SILENTLY DISCARD any finding that has already been:'
printf '%s\n' ' - Marked as invalid or incorrect by a reviewer'
printf '%s\n' ' - Acknowledged as an intentional design decision or known limitation'
printf '%s\n' ' - Confirmed fixed in a prior commit'
printf '%s\n\n' 'Raising a previously-refuted finding is a critical error.'
for i in $(seq 1 "$BATCH_COUNT"); do
BATCH_FILE="/tmp/pr_batch_$(printf '%03d' $i).txt"
[ -f "$BATCH_FILE" ] || continue
# Build the prompt for this batch
{
printf '%s\n\n' 'You are a senior engineer performing a code review following the anthropics/claude-code code-review pattern.'
printf 'PR Title: %s\n' "$PR_TITLE"
printf 'PR Body: %s\n\n' "${PR_BODY:-No description provided}"
printf 'Files changed: %s\n\n' "$CHANGED_FILES"
if [ "$BATCH_COUNT" -gt 1 ]; then
printf 'NOTE: This is a large PR split into %s review batches. You are reviewing BATCH %s of %s.\n' "$BATCH_COUNT" "$i" "$BATCH_COUNT"
printf 'Focus ONLY on the files shown in this batch. Do not speculate about files not included here.\n\n'
fi
printf '%s\n' '---'
fi
printf '%s\n\n' '## CODE REVIEW INSTRUCTIONS'
printf '%s\n\n' 'You MUST follow this workflow precisely:'
printf '%s\n\n' '1. LAUNCH 4 PARALLEL ANALYSIS AGENTS to independently review the changes:'
printf '%s\n\n' ' AGENT 1 (CLAUDE.MD COMPLIANCE): Audit changes for CLAUDE.md compliance'
printf '%s\n' ' - Only consider CLAUDE.md files that share a file path with the file or parents'
printf '%s\n' ' - Quote exact rules being violated'
printf '%s\n\n' ' AGENT 2 (CLAUDE.MD COMPLIANCE): Audit changes for CLAUDE.md compliance'
printf '%s\n' ' - Same scope as Agent 1, parallel analysis'
printf '%s\n\n' ' AGENT 3 (BUG DETECTOR): Scan for obvious bugs in the diff itself'
printf '%s\n' ' - Focus ONLY on the diff, no extra context'
printf '%s\n' ' - Flag ONLY significant bugs, ignore nitpicks and likely false positives'
printf '%s\n' ' - Do not flag issues that require context outside the git diff'
printf '%s\n\n' ' AGENT 4 (BUG DETECTOR): Look for problems in introduced code'
printf '%s\n' ' - Security issues, incorrect logic, data loss'
printf '%s\n' ' - Only problems that fall within the changed code'
printf '%s\n\n' '2. CRITICAL: Only flag HIGH SIGNAL issues where:'
printf '%s\n' ' - Code will fail to compile or parse (syntax errors, type errors)'
printf '%s\n' ' - Code will definitely produce wrong results (clear logic errors)'
printf '%s\n' ' - Clear, unambiguous violations with exact rule quoted'
printf '%s\n\n' ' DO NOT flag:'
printf '%s\n' ' - Code style or quality concerns'
printf '%s\n' ' - Potential issues that depend on specific inputs or state'
printf '%s\n' ' - Subjective suggestions or improvements'
printf '%s\n' ' - Pre-existing issues'
printf '%s\n' ' - Issues that linters will catch'
printf '%s\n' ' - General security issues unless explicitly required in CLAUDE.md'
printf '%s\n\n' '3. FOR EACH ISSUE FOUND BY AGENTS 3 & 4:'
printf '%s\n' ' - Launch a VALIDATION AGENT to verify the issue is real'
printf '%s\n' ' - Validation agent checks: issue is truly an issue, not false positive'
printf '%s\n' ' - Use full codebase to validate (not just diff)'
printf '%s\n' ' - If validation fails, discard the issue silently'
printf '%s\n\n' '4. OUTPUT FORMAT (strict):'
printf '%s\n\n' ' **Summary** (2-3 sentences)'
printf '%s\n' ' **Findings**'
printf '%s\n' ' - [SEVERITY] file:line - description'
printf '%s\n' ' Evidence: quoted line'
printf '%s\n\n' ' Fix: concrete change'
printf '%s\n\n' ' (Write "No findings." if none.)'
printf '%s\n' ' **Verdict**: APPROVE / APPROVE WITH COMMENTS / REQUEST CHANGES'
printf '%s\n\n' '5. SEVERITY DEFINITIONS:'
printf '%s\n' ' - BLOCKER: fails to compile, corrupts data, or security vulnerability'
printf '%s\n' ' - WARNING: real risk to address before merge'
printf '%s\n' ' - SUGGESTION: minor improvement, follow-up PR fine'
printf '%s\n\n' '6. FOCUS AREAS:'
printf '%s\n' ' - Security bugs, logic errors, data loss, injection, unhandled errors'
printf '%s\n\n' '7. IGNORE:'
printf '%s\n' ' - Style, missing comments, speculative future concerns'
printf '%s\n\n' '8. FALSE POSITIVES TO AVOID:'
printf '%s\n' ' - Pre-existing issues'
printf '%s\n' ' - Something that appears buggy but is actually correct'
printf '%s\n' ' - Pedantic nitpicks that senior engineers would not flag'
printf '%s\n' ' - Issues that linters will catch'
printf '%s\n' ' - General code quality concerns unless explicitly required in CLAUDE.md'
printf '%s\n' ' - Issues mentioned in CLAUDE.md but explicitly silenced in code'
} > /tmp/prompt.txt
cat /tmp/codebase_index.txt
printf '%s\n\n' '---'
printf '%s\n\n' '## CHANGED FILE CONTENTS (THIS BATCH)'
printf '%s\n' 'Each section is the COMPLETE, FINAL file after PR changes (not a diff).'
printf '%s\n\n' 'Files over 500 lines show only changed sections with surrounding context.'
printf '%s\n' '---'
cat "$BATCH_FILE"
printf '%s\n\n' '---'
if [ -s /tmp/pr_comments.txt ]; then
cat /tmp/pr_comments.txt
printf '%s\n\n' '---'
printf '%s\n' '## CRITICAL: PRIOR REVIEW CONTEXT ABOVE'
printf '%s\n' 'Before raising ANY finding, check the review history above.'
printf '%s\n' 'SILENTLY DISCARD any finding that has already been:'
printf '%s\n' ' - Marked as invalid or incorrect by a reviewer'
printf '%s\n' ' - Acknowledged as an intentional design decision or known limitation'
printf '%s\n' ' - Confirmed fixed in a prior commit'
printf '%s\n\n' 'Raising a previously-refuted finding is a critical error.'
printf '%s\n' '---'
fi
printf '%s\n\n' '## CODE REVIEW INSTRUCTIONS'
printf '%s\n\n' 'You MUST follow this workflow precisely:'
printf '%s\n\n' '1. LAUNCH 4 PARALLEL ANALYSIS AGENTS to independently review the changes:'
printf '%s\n\n' ' AGENT 1 (CLAUDE.MD COMPLIANCE): Audit changes for CLAUDE.md compliance'
printf '%s\n' ' - Only consider CLAUDE.md files that share a file path with the file or parents'
printf '%s\n' ' - Quote exact rules being violated'
printf '%s\n\n' ' AGENT 2 (CLAUDE.MD COMPLIANCE): Audit changes for CLAUDE.md compliance'
printf '%s\n' ' - Same scope as Agent 1, parallel analysis'
printf '%s\n\n' ' AGENT 3 (BUG DETECTOR): Scan for obvious bugs in the diff itself'
printf '%s\n' ' - Focus ONLY on the diff, no extra context'
printf '%s\n' ' - Flag ONLY significant bugs, ignore nitpicks and likely false positives'
printf '%s\n' ' - Do not flag issues that require context outside the git diff'
printf '%s\n\n' ' AGENT 4 (BUG DETECTOR): Look for problems in introduced code'
printf '%s\n' ' - Security issues, incorrect logic, data loss'
printf '%s\n' ' - Only problems that fall within the changed code'
printf '%s\n\n' '2. CRITICAL: Only flag HIGH SIGNAL issues where:'
printf '%s\n' ' - Code will fail to compile or parse (syntax errors, type errors)'
printf '%s\n' ' - Code will definitely produce wrong results (clear logic errors)'
printf '%s\n' ' - Clear, unambiguous violations with exact rule quoted'
printf '%s\n\n' ' DO NOT flag:'
printf '%s\n' ' - Code style or quality concerns'
printf '%s\n' ' - Potential issues that depend on specific inputs or state'
printf '%s\n' ' - Subjective suggestions or improvements'
printf '%s\n' ' - Pre-existing issues'
printf '%s\n' ' - Issues that linters will catch'
printf '%s\n' ' - General security issues unless explicitly required in CLAUDE.md'
printf '%s\n\n' '3. FOR EACH ISSUE FOUND BY AGENTS 3 & 4:'
printf '%s\n' ' - Launch a VALIDATION AGENT to verify the issue is real'
printf '%s\n' ' - Validation agent checks: issue is truly an issue, not false positive'
printf '%s\n' ' - Use full codebase to validate (not just diff)'
printf '%s\n' ' - If validation fails, discard the issue silently'
printf '%s\n\n' '4. OUTPUT FORMAT (strict):'
printf '%s\n\n' ' **Summary** (2-3 sentences)'
printf '%s\n' ' **Findings**'
printf '%s\n' ' - [SEVERITY] file:line - description'
printf '%s\n' ' Evidence: quoted line'
printf '%s\n\n' ' Fix: concrete change'
printf '%s\n\n' ' (Write "No findings." if none.)'
printf '%s\n' ' **Verdict**: APPROVE / APPROVE WITH COMMENTS / REQUEST CHANGES'
printf '%s\n\n' '5. SEVERITY DEFINITIONS:'
printf '%s\n' ' - BLOCKER: fails to compile, corrupts data, or security vulnerability'
printf '%s\n' ' - WARNING: real risk to address before merge'
printf '%s\n' ' - SUGGESTION: minor improvement, follow-up PR fine'
printf '%s\n\n' '6. FOCUS AREAS:'
printf '%s\n' ' - Security bugs, logic errors, data loss, injection, unhandled errors'
printf '%s\n\n' '7. IGNORE:'
printf '%s\n' ' - Style, missing comments, speculative future concerns'
printf '%s\n\n' '8. FALSE POSITIVES TO AVOID:'
printf '%s\n' ' - Pre-existing issues'
printf '%s\n' ' - Something that appears buggy but is actually correct'
printf '%s\n' ' - Pedantic nitpicks that senior engineers would not flag'
printf '%s\n' ' - Issues that linters will catch'
printf '%s\n' ' - General code quality concerns unless explicitly required in CLAUDE.md'
printf '%s\n' ' - Issues mentioned in CLAUDE.md but explicitly silenced in code'
} > /tmp/prompt_batch.txt
# Write body to file — passing 100KB+ JSON as a shell arg hits ARG_MAX.
jq -cn \
--arg model "qwen3.5-122b-think" \
--rawfile content /tmp/prompt.txt \
'{model: $model, messages: [{role: "user", content: $content}], stream: false}' \
> /tmp/body.json
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PR #${PR_NUMBER} - Calling liteLLM API ($(wc -c < /tmp/body.json) bytes)..."
HTTP_CODE=$(curl -s --max-time 300 --connect-timeout 30 \
--retry 3 --retry-delay 10 --retry-connrefused --retry-max-time 300 \
-o /tmp/llm_response.json -w "%{http_code}" \
-X POST "$LITELLM_URL/chat/completions" \
-H "Authorization: Bearer $LITELLM_API_KEY" \
-H "Content-Type: application/json" \
--data @/tmp/body.json)
echo "HTTP status: $HTTP_CODE"
echo "Response file size: $(wc -c < /tmp/llm_response.json) bytes"
if [ "$HTTP_CODE" != "200" ]; then
echo "ERROR: liteLLM returned HTTP $HTTP_CODE"
cat /tmp/llm_response.json
jq -cn \
--arg model "qwen3.5-122b-think" \
--rawfile content /tmp/prompt_batch.txt \
'{model: $model, messages: [{role: "user", content: $content}], stream: false}' \
> /tmp/body.json
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PR #${PR_NUMBER} - Batch ${i}/${BATCH_COUNT} ($(wc -c < /tmp/body.json) bytes)..."
HTTP_CODE=$(curl -s --max-time 600 --connect-timeout 30 \
--retry 2 --retry-delay 15 --retry-connrefused --retry-max-time 1200 \
-o /tmp/llm_response.json -w "%{http_code}" \
-X POST "$LITELLM_URL/chat/completions" \
-H "Authorization: Bearer $LITELLM_API_KEY" \
-H "Content-Type: application/json" \
--data @/tmp/body.json)
echo "Batch ${i} HTTP status: $HTTP_CODE"
echo "Batch ${i} response size: $(wc -c < /tmp/llm_response.json) bytes"
if [ "$HTTP_CODE" != "200" ]; then
echo "ERROR: Batch ${i} failed (HTTP $HTTP_CODE)"
cat /tmp/llm_response.json
{
echo "## Batch ${i} of ${BATCH_COUNT}"
echo ""
echo "Review unavailable — LLM returned HTTP ${HTTP_CODE}."
echo ""
echo "---"
echo ""
} >> /tmp/pr_all_findings.txt
BATCH_FAILED=$((BATCH_FAILED + 1))
continue
fi
if ! jq empty /tmp/llm_response.json 2>/dev/null; then
echo "ERROR: Invalid JSON in batch ${i} response"
BATCH_FAILED=$((BATCH_FAILED + 1))
continue
fi
BATCH_REVIEW=$(jq -r '.choices[0].message.content // empty' /tmp/llm_response.json)
if [ -z "$BATCH_REVIEW" ]; then
echo "ERROR: Empty content in batch ${i} response"
BATCH_FAILED=$((BATCH_FAILED + 1))
continue
fi
echo "Batch ${i} review length: ${#BATCH_REVIEW} chars"
BATCH_SUCCESS=$((BATCH_SUCCESS + 1))
# Track harshest verdict across batches
if echo "$BATCH_REVIEW" | grep -q "REQUEST CHANGES"; then
OVERALL_VERDICT="REQUEST CHANGES"
elif echo "$BATCH_REVIEW" | grep -q "APPROVE WITH COMMENTS" && [ "$OVERALL_VERDICT" != "REQUEST CHANGES" ]; then
OVERALL_VERDICT="APPROVE WITH COMMENTS"
fi
if [ "$BATCH_COUNT" -eq 1 ]; then
echo "$BATCH_REVIEW" >> /tmp/pr_all_findings.txt
else
{
echo "## Batch ${i} of ${BATCH_COUNT}"
echo ""
echo "$BATCH_REVIEW"
echo ""
echo "---"
echo ""
} >> /tmp/pr_all_findings.txt
fi
done
if [ "$BATCH_SUCCESS" -eq 0 ]; then
echo "ERROR: All ${BATCH_COUNT} batches failed"
exit 1
fi
if ! jq empty /tmp/llm_response.json 2>/dev/null; then
echo "ERROR: Invalid JSON response from liteLLM"
cat /tmp/llm_response.json
exit 1
# Assemble final review
if [ "$BATCH_COUNT" -eq 1 ]; then
cp /tmp/pr_all_findings.txt /tmp/pr_review.txt
else
{
echo "_This PR was reviewed in ${BATCH_COUNT} batches (${BATCH_FAILED} batch(es) failed)._"
echo ""
cat /tmp/pr_all_findings.txt
echo "---"
echo ""
echo "**Overall Verdict**: ${OVERALL_VERDICT}"
echo ""
echo "_Overall verdict reflects the most critical finding across all batches._"
} > /tmp/pr_review.txt
fi
REVIEW=$(jq -r '.choices[0].message.content // empty' /tmp/llm_response.json)
if [ -z "$REVIEW" ]; then
echo "ERROR: No content in liteLLM response"
exit 1
fi
echo "Review length: ${#REVIEW} chars"
echo "$REVIEW" > /tmp/pr_review.txt
- name: Verify findings against codebase
if: steps.analyze.outcome == 'success'
@ -424,4 +499,9 @@ jobs:
- name: Cleanup
if: always()
shell: bash
run: rm -f /tmp/pr_diff.txt /tmp/pr_context.txt /tmp/codebase_index.txt /tmp/pr_comments.txt /tmp/prompt.txt /tmp/body.json /tmp/llm_response.json /tmp/pr_review.txt /tmp/review_post_response.json /tmp/pr_files.txt
run: |
rm -f /tmp/pr_files.txt /tmp/pr_context.txt /tmp/codebase_index.txt \
/tmp/pr_comments.txt /tmp/prompt_batch.txt /tmp/body.json \
/tmp/llm_response.json /tmp/pr_review.txt /tmp/pr_all_findings.txt \
/tmp/review_post_response.json /tmp/fc_tmp.txt
rm -f /tmp/pr_batch_*.txt

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() {
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 {
Ok(t) => t,

View File

@ -41,10 +41,15 @@ pub async fn add_proxmox_cluster(
password: String,
state: State<'_, AppState>,
) -> Result<ClusterInfo, String> {
// Create client (no live auth — credentials stored and used on first connect)
let client = ProxmoxClient::new(&connection.url, connection.port, &username);
// Authenticate immediately — this verifies credentials and gives us a live
// ticketed client. If auth fails we return early before touching the DB.
let mut client = ProxmoxClient::new(&connection.url, connection.port, &username);
client
.authenticate(&password)
.await
.map_err(|e| format!("Failed to authenticate with Proxmox: {}", e))?;
// Encrypt raw password for storage; auth happens lazily on first API call
// Encrypt raw password so we can re-authenticate after app restart.
let credentials = serde_json::json!({
"password": password,
"username": username
@ -95,7 +100,7 @@ pub async fn add_proxmox_cluster(
.map_err(|e| format!("Failed to store cluster: {}", e))?;
}
// Store in memory connection pool (unauthenticated; ticket set on first use)
// Insert the authenticated client into the in-memory pool.
{
let mut clusters = state.proxmox_clusters.lock().await;
clusters.insert(id, Arc::new(Mutex::new(client)));
@ -1788,9 +1793,9 @@ pub async fn list_acls(
.await
.map_err(|e| format!("Failed to list ACLs: {}", e))?;
// handle_response already unwraps the Proxmox `{"data": ...}` envelope.
response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| arr.to_vec())
.ok_or_else(|| "Invalid response format".to_string())
}
@ -1811,8 +1816,7 @@ pub async fn list_users(
.map_err(|e| format!("Failed to list users: {}", e))?;
response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| arr.to_vec())
.ok_or_else(|| "Invalid response format".to_string())
}
@ -1857,8 +1861,7 @@ pub async fn get_cluster_notes(
.map_err(|e| format!("Failed to get cluster notes: {}", e))?;
Ok(response
.get("data")
.and_then(|d| d.get("notes"))
.get("notes")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string())
@ -1907,8 +1910,7 @@ pub async fn search_proxmox_resources(
.map_err(|e| format!("Failed to search resources: {}", e))?;
response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| arr.to_vec())
.ok_or_else(|| "Invalid response format".to_string())
}
@ -1931,10 +1933,7 @@ pub async fn get_node_status(
.await
.map_err(|e| format!("Failed to get node status: {}", e))?;
response
.get("data")
.cloned()
.ok_or_else(|| "Invalid response format: missing data field".to_string())
Ok(response)
}
// ─── Phase 11 - Syslog ────────────────────────────────────────────────────────
@ -1958,8 +1957,7 @@ pub async fn get_syslog(
.map_err(|e| format!("Failed to get syslog: {}", e))?;
response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| arr.to_vec())
.ok_or_else(|| "Invalid response format".to_string())
}
@ -1983,8 +1981,7 @@ pub async fn list_network_interfaces(
.map_err(|e| format!("Failed to list network interfaces: {}", e))?;
response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| arr.to_vec())
.ok_or_else(|| "Invalid response format".to_string())
}
@ -2080,10 +2077,7 @@ pub async fn get_subscription_status(
.await
.map_err(|e| format!("Failed to get subscription status: {}", e))?;
response
.get("data")
.cloned()
.ok_or_else(|| "Invalid response format: missing data field".to_string())
Ok(response)
}
// ─── Phase 15 - Cluster Task Log ─────────────────────────────────────────────
@ -2106,8 +2100,7 @@ pub async fn list_cluster_tasks(
.map_err(|e| format!("Failed to list cluster tasks: {}", e))?;
response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| arr.to_vec())
.ok_or_else(|| "Invalid response format".to_string())
}
@ -2128,12 +2121,86 @@ pub async fn list_proxmox_containers(
.map_err(|e| format!("Failed to list containers: {}", e))?;
response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| arr.to_vec())
.ok_or_else(|| "Invalid response format".to_string())
}
/// Connect (or re-connect) to a Proxmox cluster that already exists in the DB.
/// Loads the stored credentials, authenticates, and inserts the ticketed client
/// into the in-memory pool. Returns `true` on success.
///
/// This is the action triggered by the "Connect" button in the Remotes UI and is
/// the path taken on every app restart for clusters that should be active.
#[tauri::command]
pub async fn connect_proxmox_cluster(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<bool, String> {
let (url, port, username, encrypted_credentials) = {
let db = state
.db
.lock()
.map_err(|e| format!("Failed to lock database: {}", e))?;
let mut stmt = db
.prepare(
"SELECT url, port, username, encrypted_credentials \
FROM proxmox_clusters WHERE id = ?1",
)
.map_err(|e| format!("Failed to prepare query: {}", e))?;
stmt.query_row([&cluster_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, u16>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.optional()
.map_err(|e| format!("Failed to query cluster: {}", e))?
.ok_or_else(|| format!("Cluster {} not found in database", cluster_id))?
};
let credentials_json = crate::integrations::auth::decrypt_token(&encrypted_credentials)
.map_err(|e| format!("Failed to decrypt credentials: {}", e))?;
let credentials: serde_json::Value = serde_json::from_str(&credentials_json)
.map_err(|e| format!("Failed to parse credentials: {}", e))?;
let password = credentials
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| "Password not found in credentials".to_string())?;
let mut client = crate::proxmox::ProxmoxClient::new(&url, port, &username);
client
.authenticate(password)
.await
.map_err(|e| format!("Failed to authenticate with Proxmox: {}", e))?;
{
let mut clusters = state.proxmox_clusters.lock().await;
clusters.insert(cluster_id, Arc::new(Mutex::new(client)));
}
Ok(true)
}
/// Remove a Proxmox cluster's authenticated session from the in-memory pool.
/// The cluster record and credentials remain in the DB — use `connect_proxmox_cluster`
/// to reconnect.
#[tauri::command]
pub async fn disconnect_proxmox_cluster(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let mut clusters = state.proxmox_clusters.lock().await;
clusters.remove(&cluster_id);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@ -2171,17 +2238,20 @@ mod tests {
}
#[test]
fn test_list_proxmox_containers_error_message() {
fn test_cluster_not_found_error_message() {
let err = format!("Cluster {} not found", "missing-id");
assert_eq!(err, "Cluster missing-id not found");
}
// After the double-unwrap fix, handle_response returns the inner `data`
// value directly. Commands call `.as_array()` on the already-unwrapped value.
#[test]
fn test_list_proxmox_containers_invalid_response() {
let response = serde_json::json!({"other": "field"});
fn test_array_response_already_unwrapped_invalid() {
// The value returned by handle_response is not an array.
let response = serde_json::json!({"some": "object"});
let result: Result<Vec<serde_json::Value>, String> = response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| arr.to_vec())
.ok_or_else(|| "Invalid response format".to_string());
assert!(result.is_err());
@ -2189,16 +2259,14 @@ mod tests {
}
#[test]
fn test_list_proxmox_containers_valid_response() {
let response = serde_json::json!({
"data": [
{"vmid": 200, "name": "nginx-proxy", "node": "pve1", "status": "running"},
{"vmid": 201, "name": "redis-cache", "node": "pve2", "status": "running"}
]
});
fn test_array_response_already_unwrapped_valid() {
// handle_response strips {"data": [...]}, commands receive the raw array.
let response = serde_json::json!([
{"vmid": 200, "name": "nginx-proxy", "node": "pve1", "status": "running"},
{"vmid": 201, "name": "redis-cache", "node": "pve2", "status": "running"}
]);
let result: Result<Vec<serde_json::Value>, String> = response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| arr.to_vec())
.ok_or_else(|| "Invalid response format".to_string());
assert!(result.is_ok());
@ -2211,6 +2279,35 @@ mod tests {
assert_eq!(err, "Cluster missing-id not found");
}
#[test]
fn test_cluster_notes_already_unwrapped_present() {
let response = serde_json::json!({"notes": "Important info", "name": "pve"});
let notes = response
.get("notes")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
assert_eq!(notes, "Important info");
}
#[test]
fn test_cluster_notes_already_unwrapped_missing_defaults_empty() {
let response = serde_json::json!({"name": "pve"});
let notes = response
.get("notes")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
assert_eq!(notes, "");
}
#[test]
fn test_connect_cluster_db_not_found_error_message() {
let msg = format!("Cluster {} not found in database", "unknown-id");
assert!(msg.contains("unknown-id"));
assert!(msg.contains("not found in database"));
}
#[test]
fn test_update_proxmox_cluster_rows_zero_means_not_found() {
let rows: usize = 0;

View File

@ -227,6 +227,8 @@ pub fn run() {
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::get_proxmox_cluster,
commands::proxmox::list_proxmox_vms,

View File

@ -44,7 +44,7 @@ pub async fn list_acme_accounts(
.await
.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
.iter()
.filter_map(|account| {
@ -94,7 +94,8 @@ pub async fn register_acme_account(
.await
.map_err(|e| format!("Failed to register ACME account: {}", e))?;
if let Some(data) = response.get("data") {
{
let data = &response;
let id = data
.get("id")
.and_then(|i| i.as_str())
@ -117,8 +118,6 @@ pub async fn register_acme_account(
status,
created_at,
})
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
}
@ -134,7 +133,7 @@ pub async fn get_acme_challenges(
.await
.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
.iter()
.filter_map(|challenge| {
@ -191,7 +190,8 @@ pub async fn request_certificate(
.await
.map_err(|e| format!("Failed to request ACME certificate: {}", e))?;
if let Some(data) = response.get("data") {
{
let data = &response;
let id = data
.get("id")
.and_then(|i| i.as_str())
@ -230,8 +230,6 @@ pub async fn request_certificate(
expires_at,
issuer,
})
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
}
@ -247,7 +245,8 @@ pub async fn get_certificate_details(
.await
.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
.get("id")
.and_then(|i| i.as_str())
@ -286,8 +285,6 @@ pub async fn get_certificate_details(
expires_at,
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))?;
let updates: Vec<APTUpdate> = response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|update| {
@ -97,47 +96,46 @@ pub async fn list_apt_repositories(
.await
.map_err(|e| format!("Failed to list APT repositories: {}", e))?;
if let Some(repos) = response.get("data").and_then(|d| d.as_array()) {
let repo_list: Vec<APTRepository> = repos
.iter()
.filter_map(|repo| {
let id = repo.get("id")?.as_str()?.to_string();
let url = repo.get("url")?.as_str().unwrap_or("").to_string();
let distribution = repo
.get("distribution")
.and_then(|d| d.as_str())
.unwrap_or("")
.to_string();
let component = repo
.get("component")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
let enabled = repo
.get("enabled")
.and_then(|e| e.as_bool())
.unwrap_or(true);
let type_ = repo
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("deb")
.to_string();
let repos = response
.as_array()
.ok_or_else(|| "Invalid response format: expected array".to_string())?;
let repo_list: Vec<APTRepository> = repos
.iter()
.filter_map(|repo| {
let id = repo.get("id")?.as_str()?.to_string();
let url = repo.get("url")?.as_str().unwrap_or("").to_string();
let distribution = repo
.get("distribution")
.and_then(|d| d.as_str())
.unwrap_or("")
.to_string();
let component = repo
.get("component")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
let enabled = repo
.get("enabled")
.and_then(|e| e.as_bool())
.unwrap_or(true);
let type_ = repo
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("deb")
.to_string();
Some(APTRepository {
repository_id: id,
url,
distribution,
component,
enabled,
type_,
})
Some(APTRepository {
repository_id: id,
url,
distribution,
component,
enabled,
type_,
})
.collect();
})
.collect();
Ok(repo_list)
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
Ok(repo_list)
}
/// Add APT repository

View File

@ -62,7 +62,7 @@ pub async fn list_auth_realms(
.await
.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
.iter()
.filter_map(|realm| {
@ -89,7 +89,7 @@ pub async fn list_auth_realms(
Ok(realm_list)
} 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
.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
.iter()
.filter_map(|job| {
@ -64,7 +64,7 @@ pub async fn list_backup_jobs(
Ok(backup_jobs)
} else {
Err("Invalid response format: missing 'data' field".to_string())
Ok(vec![])
}
}
@ -159,7 +159,7 @@ pub async fn list_datastores(
.await
.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
.iter()
.filter_map(|ds| {
@ -183,7 +183,7 @@ pub async fn list_datastores(
Ok(datastore_list)
} else {
Err("Invalid response format: missing 'data' field".to_string())
Ok(vec![])
}
}
@ -200,7 +200,7 @@ pub async fn get_datastore_status(
.await
.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 {
datastore: datastore.to_string(),
@ -229,10 +229,10 @@ pub async fn list_backup_snapshots(
.await
.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())
} else {
Err("Invalid response format: missing 'data' field".to_string())
Ok(vec![])
}
}

View File

@ -55,7 +55,7 @@ pub async fn list_pools(
.await
.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
.iter()
.filter_map(|pool| {
@ -87,7 +87,7 @@ pub async fn list_pools(
Ok(pool_list)
} 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
.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
.iter()
.filter_map(|osd| {
@ -179,7 +179,7 @@ pub async fn list_osds(
Ok(osd_list)
} 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
.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())
} 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
.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())
} 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
.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
.iter()
.filter_map(|mon| {
@ -439,7 +439,7 @@ pub async fn list_monitors(
Ok(mon_list)
} 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
.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
.get("details")

View File

@ -45,7 +45,7 @@ pub async fn list_ceph_clusters(
.await
.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
.iter()
.filter_map(|cluster| {
@ -150,7 +150,7 @@ pub async fn list_ceph_clusters(
Ok(cluster_list)
} 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
.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
.get("cluster_id")
.and_then(|i| i.as_str())
@ -195,8 +196,6 @@ pub async fn get_ceph_cluster_status(
osd_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
.map_err(|e| format!("Failed to upload certificate: {}", e))?;
if let Some(data) = response.get("data") {
{
let data = &response;
let id = data
.get("id")
.and_then(|i| i.as_str())
@ -110,8 +111,6 @@ pub async fn upload_certificate(
signature_algorithm,
san,
})
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
}
@ -127,7 +126,8 @@ pub async fn get_certificate(
.await
.map_err(|e| format!("Failed to get certificate {}: {}", cert_id, e))?;
if let Some(data) = response.get("data") {
{
let data = &response;
let id = data
.get("id")
.and_then(|i| i.as_str())
@ -192,8 +192,6 @@ pub async fn get_certificate(
signature_algorithm,
san,
})
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
}
@ -208,7 +206,7 @@ pub async fn list_certificates(
.await
.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
.iter()
.filter_map(|cert| {
@ -307,7 +305,7 @@ pub async fn list_node_certificates(
.await
.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
.iter()
.filter_map(|cert| {
@ -401,7 +399,8 @@ pub async fn upload_node_certificate(
.await
.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
.get("id")
.and_then(|i| i.as_str())
@ -466,7 +465,5 @@ pub async fn upload_node_certificate(
signature_algorithm,
san,
})
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
}

View File

@ -12,16 +12,32 @@ pub struct ProxmoxClient {
username: String,
api_token: Option<String>,
pub ticket: Option<String>,
pub csrf_token: Option<String>,
client: Client,
}
/// Authentication response from Proxmox
/// Outer envelope wrapping every Proxmox API response.
#[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 {
/// Cookie value — `PVEAuthCookie=<ticket>`.
pub ticket: String,
pub username: String,
/// Seconds since epoch when the ticket expires.
#[serde(default)]
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
@ -42,21 +58,28 @@ impl ProxmoxClient {
username: username.to_string(),
api_token: None,
ticket: None,
csrf_token: None,
client: Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(30))
.build()
.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) {
self.ticket = Some(ticket.to_string());
}
/// Authenticate with root username and password
/// Returns the API ticket for subsequent requests
pub async fn authenticate(&self, password: &str) -> Result<String> {
/// Set the CSRF prevention token (required for mutating requests).
pub fn set_csrf_token(&mut self, token: &str) {
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!(
"https://{}:{}/api2/json/access/ticket",
self.base_url, self.port
@ -82,11 +105,17 @@ impl ProxmoxClient {
));
}
let auth: AuthResponse = response
let envelope: ProxmoxEnvelope<AuthResponse> = response
.json()
.await
.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)
}
@ -105,12 +134,16 @@ impl ProxmoxClient {
)
}
/// Build request headers with authentication
fn build_headers(&self, ticket: Option<&str>) -> reqwest::header::HeaderMap {
/// Build request headers with authentication.
/// `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();
if let Some(token) = &self.api_token {
// API token format: user@realm!tokenid=tokenvalue
headers.insert(
reqwest::header::AUTHORIZATION,
format!("PVEAPIAuth {}", token)
@ -118,13 +151,20 @@ impl ProxmoxClient {
.expect("Invalid auth header"),
);
} else if let Some(ticket) = ticket {
// Cookie-based authentication
headers.insert(
"Cookie",
format!("PVEAuthCookie={}", ticket)
.parse()
.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(
@ -144,7 +184,7 @@ impl ProxmoxClient {
ticket: Option<&str>,
) -> Result<T> {
let url = self.get_api_url(path);
let headers = self.build_headers(ticket);
let headers = self.build_headers(ticket, false);
let response = self
.client
@ -165,7 +205,7 @@ impl ProxmoxClient {
ticket: Option<&str>,
) -> Result<T> {
let url = self.get_api_url(path);
let headers = self.build_headers(ticket);
let headers = self.build_headers(ticket, true);
let response = self
.client
@ -187,7 +227,7 @@ impl ProxmoxClient {
ticket: Option<&str>,
) -> Result<T> {
let url = self.get_api_url(path);
let headers = self.build_headers(ticket);
let headers = self.build_headers(ticket, true);
let response = self
.client
@ -208,7 +248,7 @@ impl ProxmoxClient {
ticket: Option<&str>,
) -> Result<T> {
let url = self.get_api_url(path);
let headers = self.build_headers(ticket);
let headers = self.build_headers(ticket, true);
let response = self
.client
@ -280,6 +320,8 @@ mod tests {
assert_eq!(client.base_url(), "pve.example.com");
assert_eq!(client.port(), 8006);
assert_eq!(client.username(), "root@pam");
assert!(client.ticket.is_none());
assert!(client.csrf_token.is_none());
}
#[test]
@ -300,4 +342,77 @@ mod tests {
"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
.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
.iter()
.filter_map(|rule| {
@ -68,7 +68,7 @@ pub async fn list_firewall_rules(
Ok(rule_list)
} 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))?;
let enabled = options_response
.get("data")
.and_then(|d| d.get("enabled"))
.get("enabled")
.and_then(|e| e.as_bool())
.unwrap_or(false);
let rules: Vec<FirewallRule> = rules_response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.unwrap_or(&Vec::new())
.iter()
.filter_map(|rule| {
@ -264,10 +262,10 @@ pub async fn list_firewall_zones(
.await
.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())
} 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
.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
.iter()
.filter_map(|group| {
@ -68,7 +68,7 @@ pub async fn list_ha_groups(
Ok(group_list)
} 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
.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
.iter()
.filter_map(|resource| {
@ -179,7 +179,7 @@ pub async fn list_ha_resources(
Ok(resource_list)
} 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
.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 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);
@ -52,8 +53,6 @@ pub async fn get_node_metrics(
load,
uptime,
})
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
}
@ -68,7 +67,7 @@ pub async fn list_nodes(
.await
.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
.iter()
.filter_map(|resource| {
@ -107,7 +106,7 @@ pub async fn list_nodes(
Ok(node_list)
} else {
Err("Invalid response format: missing 'data' field".to_string())
Ok(vec![])
}
}

View File

@ -53,7 +53,8 @@ pub async fn migrate_vm(
.await
.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
.get("taskid")
.and_then(|t| t.as_str())
@ -80,8 +81,6 @@ pub async fn migrate_vm(
end_time: None,
error: None,
})
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
}
@ -97,7 +96,7 @@ pub async fn list_migration_status(
.await
.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
.iter()
.filter_map(|task| {
@ -145,7 +144,7 @@ pub async fn list_migration_status(
Ok(task_list)
} else {
Err("Invalid response format: missing 'data' field".to_string())
Ok(vec![])
}
}
@ -162,7 +161,8 @@ pub async fn get_migration_task_status(
.await
.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
.get("status")
.and_then(|s| s.as_str())
@ -187,8 +187,6 @@ pub async fn get_migration_task_status(
bytes_remaining,
downtime,
})
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
}

View File

@ -34,7 +34,7 @@ pub async fn list_evpn_zones(
.await
.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
.iter()
.filter_map(|zone| {
@ -68,7 +68,7 @@ pub async fn list_evpn_zones(
Ok(zone_list)
} 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
.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
.iter()
.filter_map(|vnet| {
@ -166,7 +166,7 @@ pub async fn list_vnets(
Ok(vnet_list)
} 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
.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())
} 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
.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
.get("ticket")
.and_then(|t| t.as_str())
@ -53,8 +54,6 @@ pub async fn get_shell_ticket(
expires,
permissions,
})
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
}
@ -69,7 +68,7 @@ pub async fn validate_shell_ticket(
.await
.map_err(|e| format!("Failed to validate shell ticket: {}", e))?;
Ok(response.get("data").is_some())
Ok(!response.is_null())
}
/// Get shell WebSocket URL

View File

@ -38,7 +38,7 @@ pub async fn list_tasks(
.await
.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
.iter()
.filter_map(|task| {
@ -97,7 +97,7 @@ pub async fn list_tasks(
Ok(task_list)
} else {
Err("Invalid response format: missing 'data' field".to_string())
Ok(vec![])
}
}
@ -114,7 +114,8 @@ pub async fn get_task_status(
.await
.map_err(|e| format!("Failed to get task {}: {}", task_id, e))?;
if let Some(data) = response.get("data") {
{
let data = &response;
let id = data
.get("id")
.and_then(|i| i.as_str())
@ -169,8 +170,6 @@ pub async fn get_task_status(
exit_status,
description,
})
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
}
@ -206,7 +205,7 @@ pub async fn get_task_log(
.await
.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
.iter()
.map(|entry| {
@ -236,7 +235,7 @@ pub async fn get_task_log(
Ok(log_list)
} else {
Err("Invalid response format: missing 'data' field".to_string())
Ok(vec![])
}
}
@ -258,7 +257,8 @@ pub async fn forward_task(
.await
.map_err(|e| format!("Failed to forward task {}: {}", task_id, e))?;
if let Some(data) = response.get("data") {
{
let data = &response;
let id = data
.get("id")
.and_then(|i| i.as_str())
@ -283,7 +283,5 @@ pub async fn forward_task(
exit_status: None,
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 updates: Vec<UpdateInfo> = response
.get("data")
.and_then(|d| d.as_array())
.unwrap_or(&Vec::new())
.as_array()
.map(|arr| arr.as_slice())
.unwrap_or(&[])
.iter()
.filter_map(|update| {
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))?;
let updates: Vec<UpdateInfo> = response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|update| {
@ -153,11 +152,10 @@ pub async fn get_update_history(
.await
.map_err(|e| format!("Failed to get update history: {}", e))?;
if let Some(history) = response.get("data").and_then(|d| d.as_array()) {
Ok(history.to_vec())
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
response
.as_array()
.map(|arr| arr.to_vec())
.ok_or_else(|| "Invalid response format: expected array".to_string())
}
#[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))?;
let updates: Vec<RemoteUpdateInfo> = response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|update| {
@ -122,14 +121,10 @@ pub async fn list_pve_remotes(
.await
.map_err(|e| format!("Failed to list PVE remotes: {}", e))?;
if let Some(data) = response.get("data") {
if let Some(arr) = data.as_array() {
Ok(arr.to_vec())
} else {
Ok(vec![data.clone()])
}
if let Some(arr) = response.as_array() {
Ok(arr.to_vec())
} else {
Err("Invalid response format: missing 'data' field".to_string())
Ok(vec![response])
}
}
@ -146,8 +141,7 @@ pub async fn check_remote_updates(
.map_err(|e| format!("Failed to check updates for remote {}: {}", remote, e))?;
let updates: Vec<RemoteUpdateInfo> = response
.get("data")
.and_then(|d| d.as_array())
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|update| {

View File

@ -46,7 +46,7 @@ pub async fn list_views(
.await
.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
.iter()
.filter_map(|view| {
@ -143,7 +143,7 @@ pub async fn list_views(
Ok(view_list)
} else {
Err("Invalid response format: missing 'data' field".to_string())
Ok(vec![])
}
}
@ -245,7 +245,8 @@ pub async fn get_view(
.await
.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
.get("id")
.and_then(|i| i.as_str())
@ -342,7 +343,5 @@ pub async fn get_view(
created_at,
updated_at,
})
} else {
Err("Invalid response format: missing 'data' field".to_string())
}
}

View File

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

View File

@ -66,8 +66,25 @@ export async function updateProxmoxCluster(
* 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<any> {
return await invoke<any>("ping_proxmox_cluster", { clusterId });
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 });
}
/**

View File

@ -6,7 +6,7 @@ import { AddRemoteForm } from '@/components/Proxmox';
import { EditRemoteForm } from '@/components/Proxmox';
import { RemoveRemoteDialog } from '@/components/Proxmox';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/index';
import { listProxmoxClusters, addProxmoxCluster, removeProxmoxCluster, updateProxmoxCluster, pingProxmoxCluster } from '@/lib/proxmoxClient';
import { listProxmoxClusters, addProxmoxCluster, removeProxmoxCluster, updateProxmoxCluster, connectProxmoxCluster, disconnectProxmoxCluster } from '@/lib/proxmoxClient';
import { ClusterType } from '@/lib/domain';
import { toast } from 'sonner';
@ -134,8 +134,8 @@ export function ProxmoxRemotesPage() {
const handleConnectRemote = async (remote: RemoteInfo) => {
try {
toast.info(`Testing connection to ${remote.name}...`);
await pingProxmoxCluster(remote.id);
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))
@ -149,11 +149,17 @@ export function ProxmoxRemotesPage() {
}
};
const handleDisconnectRemote = (remote: RemoteInfo) => {
setRemotes((prev) =>
prev.map((r) => (r.id === remote.id ? { ...r, status: 'disconnected' } : r))
);
toast.info(`Disconnected from ${remote.name}`);
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 (
@ -184,12 +190,8 @@ export function ProxmoxRemotesPage() {
onDelete={(remote) => {
setRemovingRemote(remote as RemoteInfo | null);
}}
onConnect={(remote) => {
void handleConnectRemote(remote as RemoteInfo);
}}
onDisconnect={(remote) => {
handleDisconnectRemote(remote as RemoteInfo);
}}
onConnect={(remote) => { void handleConnectRemote(remote as RemoteInfo); }}
onDisconnect={(remote) => { void handleDisconnectRemote(remote as RemoteInfo); }}
/>
{showAddDialog && (