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
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:
commit
3edb00dfb0
@ -31,7 +31,7 @@ jobs:
|
|||||||
git fetch --depth=1 origin ${{ github.head_ref }}
|
git fetch --depth=1 origin ${{ github.head_ref }}
|
||||||
git checkout FETCH_HEAD
|
git checkout FETCH_HEAD
|
||||||
|
|
||||||
- name: Build review context
|
- name: Build review batches
|
||||||
id: context
|
id: context
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@ -49,13 +49,10 @@ jobs:
|
|||||||
if [ "$FILE_COUNT" -eq 0 ]; then
|
if [ "$FILE_COUNT" -eq 0 ]; then
|
||||||
echo "No reviewable files changed."
|
echo "No reviewable files changed."
|
||||||
echo "diff_size=0" >> $GITHUB_OUTPUT
|
echo "diff_size=0" >> $GITHUB_OUTPUT
|
||||||
|
echo "batch_count=0" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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,
|
# Secret scrubbing: match actual credential VALUES only — known API key formats,
|
||||||
# or keyword="long_quoted_literal" (25+ chars). Never scrub on keyword alone,
|
# or keyword="long_quoted_literal" (25+ chars). Never scrub on keyword alone,
|
||||||
# which would silently delete function signatures, variable declarations, and tests.
|
# 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)
|
# 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:]]*$'
|
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
|
while IFS= read -r file; do
|
||||||
[ -f "$file" ] || continue
|
[ -f "$file" ] || continue
|
||||||
lines=$(wc -l < "$file" | tr -d ' ')
|
file_lines=$(wc -l < "$file" | tr -d ' ')
|
||||||
printf '\n════════ FILE: %s (%s lines) ════════\n' "$file" "$lines" >> /tmp/pr_context.txt
|
|
||||||
if [ "$lines" -le 500 ]; then
|
# Build context for this individual file into a temp file
|
||||||
# Full file — model sees the complete implementation
|
{
|
||||||
grep -v -E "$SECRET_PATTERN" "$file" \
|
printf '\n════════ FILE: %s (%s lines) ════════\n' "$file" "$file_lines"
|
||||||
| grep -v -E "$B64_PATTERN" \
|
if [ "$file_lines" -le 500 ]; then
|
||||||
>> /tmp/pr_context.txt || true
|
grep -v -E "$SECRET_PATTERN" "$file" | grep -v -E "$B64_PATTERN" || true
|
||||||
else
|
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' "$file_lines"
|
||||||
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" \
|
||||||
git diff -U50 origin/${{ github.base_ref }}..HEAD -- "$file" \
|
| grep -v -E "$SECRET_PATTERN" \
|
||||||
| grep -v -E "$SECRET_PATTERN" \
|
| grep -v -E "$B64_PATTERN" \
|
||||||
| grep -v -E "$B64_PATTERN" \
|
|| true
|
||||||
>> /tmp/pr_context.txt || 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
|
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
|
done < /tmp/pr_files.txt
|
||||||
|
|
||||||
TOTAL=$(wc -l < /tmp/pr_context.txt | tr -d ' ')
|
rm -f /tmp/fc_tmp.txt
|
||||||
echo "diff_size=${TOTAL}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# Cap at 6000 lines so we stay within the model's context window
|
echo "batch_count=${BATCH}" >> $GITHUB_OUTPUT
|
||||||
if [ "$TOTAL" -gt 6000 ]; then
|
echo "diff_size=${TOTAL_LINES}" >> $GITHUB_OUTPUT
|
||||||
head -n 6000 /tmp/pr_context.txt > /tmp/pr_context_capped.txt
|
echo "Built ${BATCH} batch(es) from ${FILE_COUNT} files (${TOTAL_LINES} total lines)"
|
||||||
mv /tmp/pr_context_capped.txt /tmp/pr_context.txt
|
|
||||||
echo "[CONTEXT TRUNCATED at 6000 lines — ${TOTAL} total]" >> /tmp/pr_context.txt
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build codebase index
|
- name: Build codebase index
|
||||||
id: index
|
id: index
|
||||||
@ -175,7 +190,7 @@ jobs:
|
|||||||
echo "comment_lines=${LINES}" >> $GITHUB_OUTPUT
|
echo "comment_lines=${LINES}" >> $GITHUB_OUTPUT
|
||||||
echo "Fetched PR history: ${LINES} lines"
|
echo "Fetched PR history: ${LINES} lines"
|
||||||
|
|
||||||
- name: Analyze with LLM
|
- name: Analyze iteratively
|
||||||
id: analyze
|
id: analyze
|
||||||
if: steps.context.outputs.diff_size != '0'
|
if: steps.context.outputs.diff_size != '0'
|
||||||
shell: bash
|
shell: bash
|
||||||
@ -185,137 +200,197 @@ jobs:
|
|||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
PR_BODY: ${{ github.event.pull_request.body }}
|
PR_BODY: ${{ github.event.pull_request.body }}
|
||||||
|
BATCH_COUNT: ${{ steps.context.outputs.batch_count }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
CHANGED_FILES=$(tr '\n' ' ' < /tmp/pr_files.txt)
|
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:
|
for i in $(seq 1 "$BATCH_COUNT"); do
|
||||||
# - Multi-agent review (parallel analysis)
|
BATCH_FILE="/tmp/pr_batch_$(printf '%03d' $i).txt"
|
||||||
# - High-signal issues only (no nitpicks, style, or speculative concerns)
|
[ -f "$BATCH_FILE" ] || continue
|
||||||
# - Validate findings against codebase
|
|
||||||
# - Consider PR title/description for author intent
|
# Build the prompt for this batch
|
||||||
# - 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 '%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 Title: %s\n' "$PR_TITLE"
|
printf 'PR Body: %s\n\n' "${PR_BODY:-No description provided}"
|
||||||
printf 'PR Body: %s\n\n' "${PR_BODY:-No description provided}"
|
printf 'Files changed: %s\n\n' "$CHANGED_FILES"
|
||||||
printf 'Files changed: %s\n\n' "$CHANGED_FILES"
|
if [ "$BATCH_COUNT" -gt 1 ]; then
|
||||||
printf '%s\n' '---'
|
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 '%s\n\n' '## CODEBASE INDEX'
|
printf 'Focus ONLY on the files shown in this batch. Do not speculate about files not included here.\n\n'
|
||||||
printf '%s\n' 'These are the ONLY Tauri commands, TypeScript exports, Rust public functions,'
|
fi
|
||||||
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.'
|
|
||||||
printf '%s\n' '---'
|
printf '%s\n' '---'
|
||||||
fi
|
cat /tmp/codebase_index.txt
|
||||||
printf '%s\n\n' '## CODE REVIEW INSTRUCTIONS'
|
printf '%s\n\n' '---'
|
||||||
printf '%s\n\n' 'You MUST follow this workflow precisely:'
|
printf '%s\n\n' '## CHANGED FILE CONTENTS (THIS BATCH)'
|
||||||
printf '%s\n\n' '1. LAUNCH 4 PARALLEL ANALYSIS AGENTS to independently review the changes:'
|
printf '%s\n' 'Each section is the COMPLETE, FINAL file after PR changes (not a diff).'
|
||||||
printf '%s\n\n' ' AGENT 1 (CLAUDE.MD COMPLIANCE): Audit changes for CLAUDE.md compliance'
|
printf '%s\n\n' 'Files over 500 lines show only changed sections with surrounding context.'
|
||||||
printf '%s\n' ' - Only consider CLAUDE.md files that share a file path with the file or parents'
|
printf '%s\n' '---'
|
||||||
printf '%s\n' ' - Quote exact rules being violated'
|
cat "$BATCH_FILE"
|
||||||
printf '%s\n\n' ' AGENT 2 (CLAUDE.MD COMPLIANCE): Audit changes for CLAUDE.md compliance'
|
printf '%s\n\n' '---'
|
||||||
printf '%s\n' ' - Same scope as Agent 1, parallel analysis'
|
if [ -s /tmp/pr_comments.txt ]; then
|
||||||
printf '%s\n\n' ' AGENT 3 (BUG DETECTOR): Scan for obvious bugs in the diff itself'
|
cat /tmp/pr_comments.txt
|
||||||
printf '%s\n' ' - Focus ONLY on the diff, no extra context'
|
printf '%s\n\n' '---'
|
||||||
printf '%s\n' ' - Flag ONLY significant bugs, ignore nitpicks and likely false positives'
|
printf '%s\n' '## CRITICAL: PRIOR REVIEW CONTEXT ABOVE'
|
||||||
printf '%s\n' ' - Do not flag issues that require context outside the git diff'
|
printf '%s\n' 'Before raising ANY finding, check the review history above.'
|
||||||
printf '%s\n\n' ' AGENT 4 (BUG DETECTOR): Look for problems in introduced code'
|
printf '%s\n' 'SILENTLY DISCARD any finding that has already been:'
|
||||||
printf '%s\n' ' - Security issues, incorrect logic, data loss'
|
printf '%s\n' ' - Marked as invalid or incorrect by a reviewer'
|
||||||
printf '%s\n' ' - Only problems that fall within the changed code'
|
printf '%s\n' ' - Acknowledged as an intentional design decision or known limitation'
|
||||||
printf '%s\n\n' '2. CRITICAL: Only flag HIGH SIGNAL issues where:'
|
printf '%s\n' ' - Confirmed fixed in a prior commit'
|
||||||
printf '%s\n' ' - Code will fail to compile or parse (syntax errors, type errors)'
|
printf '%s\n\n' 'Raising a previously-refuted finding is a critical error.'
|
||||||
printf '%s\n' ' - Code will definitely produce wrong results (clear logic errors)'
|
printf '%s\n' '---'
|
||||||
printf '%s\n' ' - Clear, unambiguous violations with exact rule quoted'
|
fi
|
||||||
printf '%s\n\n' ' DO NOT flag:'
|
printf '%s\n\n' '## CODE REVIEW INSTRUCTIONS'
|
||||||
printf '%s\n' ' - Code style or quality concerns'
|
printf '%s\n\n' 'You MUST follow this workflow precisely:'
|
||||||
printf '%s\n' ' - Potential issues that depend on specific inputs or state'
|
printf '%s\n\n' '1. LAUNCH 4 PARALLEL ANALYSIS AGENTS to independently review the changes:'
|
||||||
printf '%s\n' ' - Subjective suggestions or improvements'
|
printf '%s\n\n' ' AGENT 1 (CLAUDE.MD COMPLIANCE): Audit changes for CLAUDE.md compliance'
|
||||||
printf '%s\n' ' - Pre-existing issues'
|
printf '%s\n' ' - Only consider CLAUDE.md files that share a file path with the file or parents'
|
||||||
printf '%s\n' ' - Issues that linters will catch'
|
printf '%s\n' ' - Quote exact rules being violated'
|
||||||
printf '%s\n' ' - General security issues unless explicitly required in CLAUDE.md'
|
printf '%s\n\n' ' AGENT 2 (CLAUDE.MD COMPLIANCE): Audit changes for CLAUDE.md compliance'
|
||||||
printf '%s\n\n' '3. FOR EACH ISSUE FOUND BY AGENTS 3 & 4:'
|
printf '%s\n' ' - Same scope as Agent 1, parallel analysis'
|
||||||
printf '%s\n' ' - Launch a VALIDATION AGENT to verify the issue is real'
|
printf '%s\n\n' ' AGENT 3 (BUG DETECTOR): Scan for obvious bugs in the diff itself'
|
||||||
printf '%s\n' ' - Validation agent checks: issue is truly an issue, not false positive'
|
printf '%s\n' ' - Focus ONLY on the diff, no extra context'
|
||||||
printf '%s\n' ' - Use full codebase to validate (not just diff)'
|
printf '%s\n' ' - Flag ONLY significant bugs, ignore nitpicks and likely false positives'
|
||||||
printf '%s\n' ' - If validation fails, discard the issue silently'
|
printf '%s\n' ' - Do not flag issues that require context outside the git diff'
|
||||||
printf '%s\n\n' '4. OUTPUT FORMAT (strict):'
|
printf '%s\n\n' ' AGENT 4 (BUG DETECTOR): Look for problems in introduced code'
|
||||||
printf '%s\n\n' ' **Summary** (2-3 sentences)'
|
printf '%s\n' ' - Security issues, incorrect logic, data loss'
|
||||||
printf '%s\n' ' **Findings**'
|
printf '%s\n' ' - Only problems that fall within the changed code'
|
||||||
printf '%s\n' ' - [SEVERITY] file:line - description'
|
printf '%s\n\n' '2. CRITICAL: Only flag HIGH SIGNAL issues where:'
|
||||||
printf '%s\n' ' Evidence: quoted line'
|
printf '%s\n' ' - Code will fail to compile or parse (syntax errors, type errors)'
|
||||||
printf '%s\n\n' ' Fix: concrete change'
|
printf '%s\n' ' - Code will definitely produce wrong results (clear logic errors)'
|
||||||
printf '%s\n\n' ' (Write "No findings." if none.)'
|
printf '%s\n' ' - Clear, unambiguous violations with exact rule quoted'
|
||||||
printf '%s\n' ' **Verdict**: APPROVE / APPROVE WITH COMMENTS / REQUEST CHANGES'
|
printf '%s\n\n' ' DO NOT flag:'
|
||||||
printf '%s\n\n' '5. SEVERITY DEFINITIONS:'
|
printf '%s\n' ' - Code style or quality concerns'
|
||||||
printf '%s\n' ' - BLOCKER: fails to compile, corrupts data, or security vulnerability'
|
printf '%s\n' ' - Potential issues that depend on specific inputs or state'
|
||||||
printf '%s\n' ' - WARNING: real risk to address before merge'
|
printf '%s\n' ' - Subjective suggestions or improvements'
|
||||||
printf '%s\n' ' - SUGGESTION: minor improvement, follow-up PR fine'
|
printf '%s\n' ' - Pre-existing issues'
|
||||||
printf '%s\n\n' '6. FOCUS AREAS:'
|
printf '%s\n' ' - Issues that linters will catch'
|
||||||
printf '%s\n' ' - Security bugs, logic errors, data loss, injection, unhandled errors'
|
printf '%s\n' ' - General security issues unless explicitly required in CLAUDE.md'
|
||||||
printf '%s\n\n' '7. IGNORE:'
|
printf '%s\n\n' '3. FOR EACH ISSUE FOUND BY AGENTS 3 & 4:'
|
||||||
printf '%s\n' ' - Style, missing comments, speculative future concerns'
|
printf '%s\n' ' - Launch a VALIDATION AGENT to verify the issue is real'
|
||||||
printf '%s\n\n' '8. FALSE POSITIVES TO AVOID:'
|
printf '%s\n' ' - Validation agent checks: issue is truly an issue, not false positive'
|
||||||
printf '%s\n' ' - Pre-existing issues'
|
printf '%s\n' ' - Use full codebase to validate (not just diff)'
|
||||||
printf '%s\n' ' - Something that appears buggy but is actually correct'
|
printf '%s\n' ' - If validation fails, discard the issue silently'
|
||||||
printf '%s\n' ' - Pedantic nitpicks that senior engineers would not flag'
|
printf '%s\n\n' '4. OUTPUT FORMAT (strict):'
|
||||||
printf '%s\n' ' - Issues that linters will catch'
|
printf '%s\n\n' ' **Summary** (2-3 sentences)'
|
||||||
printf '%s\n' ' - General code quality concerns unless explicitly required in CLAUDE.md'
|
printf '%s\n' ' **Findings**'
|
||||||
printf '%s\n' ' - Issues mentioned in CLAUDE.md but explicitly silenced in code'
|
printf '%s\n' ' - [SEVERITY] file:line - description'
|
||||||
} > /tmp/prompt.txt
|
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 \
|
||||||
jq -cn \
|
--arg model "qwen3.5-122b-think" \
|
||||||
--arg model "qwen3.5-122b-think" \
|
--rawfile content /tmp/prompt_batch.txt \
|
||||||
--rawfile content /tmp/prompt.txt \
|
'{model: $model, messages: [{role: "user", content: $content}], stream: false}' \
|
||||||
'{model: $model, messages: [{role: "user", content: $content}], stream: false}' \
|
> /tmp/body.json
|
||||||
> /tmp/body.json
|
|
||||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PR #${PR_NUMBER} - Calling liteLLM API ($(wc -c < /tmp/body.json) bytes)..."
|
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 300 --connect-timeout 30 \
|
|
||||||
--retry 3 --retry-delay 10 --retry-connrefused --retry-max-time 300 \
|
HTTP_CODE=$(curl -s --max-time 600 --connect-timeout 30 \
|
||||||
-o /tmp/llm_response.json -w "%{http_code}" \
|
--retry 2 --retry-delay 15 --retry-connrefused --retry-max-time 1200 \
|
||||||
-X POST "$LITELLM_URL/chat/completions" \
|
-o /tmp/llm_response.json -w "%{http_code}" \
|
||||||
-H "Authorization: Bearer $LITELLM_API_KEY" \
|
-X POST "$LITELLM_URL/chat/completions" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Authorization: Bearer $LITELLM_API_KEY" \
|
||||||
--data @/tmp/body.json)
|
-H "Content-Type: application/json" \
|
||||||
echo "HTTP status: $HTTP_CODE"
|
--data @/tmp/body.json)
|
||||||
echo "Response file size: $(wc -c < /tmp/llm_response.json) bytes"
|
|
||||||
if [ "$HTTP_CODE" != "200" ]; then
|
echo "Batch ${i} HTTP status: $HTTP_CODE"
|
||||||
echo "ERROR: liteLLM returned HTTP $HTTP_CODE"
|
echo "Batch ${i} response size: $(wc -c < /tmp/llm_response.json) bytes"
|
||||||
cat /tmp/llm_response.json
|
|
||||||
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! jq empty /tmp/llm_response.json 2>/dev/null; then
|
|
||||||
echo "ERROR: Invalid JSON response from liteLLM"
|
# Assemble final review
|
||||||
cat /tmp/llm_response.json
|
if [ "$BATCH_COUNT" -eq 1 ]; then
|
||||||
exit 1
|
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
|
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
|
- name: Verify findings against codebase
|
||||||
if: steps.analyze.outcome == 'success'
|
if: steps.analyze.outcome == 'success'
|
||||||
@ -424,4 +499,9 @@ jobs:
|
|||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
if: always()
|
if: always()
|
||||||
shell: bash
|
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
|
||||||
|
|||||||
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,
|
||||||
|
|||||||
@ -41,10 +41,15 @@ pub async fn add_proxmox_cluster(
|
|||||||
password: String,
|
password: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<ClusterInfo, String> {
|
) -> Result<ClusterInfo, String> {
|
||||||
// Create client (no live auth — credentials stored and used on first connect)
|
// Authenticate immediately — this verifies credentials and gives us a live
|
||||||
let client = ProxmoxClient::new(&connection.url, connection.port, &username);
|
// 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!({
|
let credentials = serde_json::json!({
|
||||||
"password": password,
|
"password": password,
|
||||||
"username": username
|
"username": username
|
||||||
@ -95,7 +100,7 @@ pub async fn add_proxmox_cluster(
|
|||||||
.map_err(|e| format!("Failed to store cluster: {}", e))?;
|
.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;
|
let mut clusters = state.proxmox_clusters.lock().await;
|
||||||
clusters.insert(id, Arc::new(Mutex::new(client)));
|
clusters.insert(id, Arc::new(Mutex::new(client)));
|
||||||
@ -1788,9 +1793,9 @@ pub async fn list_acls(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to list ACLs: {}", e))?;
|
.map_err(|e| format!("Failed to list ACLs: {}", e))?;
|
||||||
|
|
||||||
|
// handle_response already unwraps the Proxmox `{"data": ...}` envelope.
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.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))?;
|
.map_err(|e| format!("Failed to list users: {}", e))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.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))?;
|
.map_err(|e| format!("Failed to get cluster notes: {}", e))?;
|
||||||
|
|
||||||
Ok(response
|
Ok(response
|
||||||
.get("data")
|
.get("notes")
|
||||||
.and_then(|d| d.get("notes"))
|
|
||||||
.and_then(|n| n.as_str())
|
.and_then(|n| n.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string())
|
.to_string())
|
||||||
@ -1907,8 +1910,7 @@ pub async fn search_proxmox_resources(
|
|||||||
.map_err(|e| format!("Failed to search resources: {}", e))?;
|
.map_err(|e| format!("Failed to search resources: {}", e))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.ok_or_else(|| "Invalid response format".to_string())
|
||||||
}
|
}
|
||||||
@ -1931,10 +1933,7 @@ pub async fn get_node_status(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to get node status: {}", e))?;
|
.map_err(|e| format!("Failed to get node status: {}", e))?;
|
||||||
|
|
||||||
response
|
Ok(response)
|
||||||
.get("data")
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| "Invalid response format: missing data field".to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Phase 11 - Syslog ────────────────────────────────────────────────────────
|
// ─── Phase 11 - Syslog ────────────────────────────────────────────────────────
|
||||||
@ -1958,8 +1957,7 @@ pub async fn get_syslog(
|
|||||||
.map_err(|e| format!("Failed to get syslog: {}", e))?;
|
.map_err(|e| format!("Failed to get syslog: {}", e))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.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))?;
|
.map_err(|e| format!("Failed to list network interfaces: {}", e))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.ok_or_else(|| "Invalid response format".to_string())
|
||||||
}
|
}
|
||||||
@ -2080,10 +2077,7 @@ pub async fn get_subscription_status(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to get subscription status: {}", e))?;
|
.map_err(|e| format!("Failed to get subscription status: {}", e))?;
|
||||||
|
|
||||||
response
|
Ok(response)
|
||||||
.get("data")
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| "Invalid response format: missing data field".to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Phase 15 - Cluster Task Log ─────────────────────────────────────────────
|
// ─── 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))?;
|
.map_err(|e| format!("Failed to list cluster tasks: {}", e))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.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))?;
|
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -2171,17 +2238,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_proxmox_containers_error_message() {
|
fn test_cluster_not_found_error_message() {
|
||||||
let err = format!("Cluster {} not found", "missing-id");
|
let err = format!("Cluster {} not found", "missing-id");
|
||||||
assert_eq!(err, "Cluster missing-id not found");
|
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]
|
#[test]
|
||||||
fn test_list_proxmox_containers_invalid_response() {
|
fn test_array_response_already_unwrapped_invalid() {
|
||||||
let response = serde_json::json!({"other": "field"});
|
// 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
|
let result: Result<Vec<serde_json::Value>, String> = response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string());
|
.ok_or_else(|| "Invalid response format".to_string());
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
@ -2189,16 +2259,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_proxmox_containers_valid_response() {
|
fn test_array_response_already_unwrapped_valid() {
|
||||||
let response = serde_json::json!({
|
// handle_response strips {"data": [...]}, commands receive the raw array.
|
||||||
"data": [
|
let response = serde_json::json!([
|
||||||
{"vmid": 200, "name": "nginx-proxy", "node": "pve1", "status": "running"},
|
{"vmid": 200, "name": "nginx-proxy", "node": "pve1", "status": "running"},
|
||||||
{"vmid": 201, "name": "redis-cache", "node": "pve2", "status": "running"}
|
{"vmid": 201, "name": "redis-cache", "node": "pve2", "status": "running"}
|
||||||
]
|
]);
|
||||||
});
|
|
||||||
let result: Result<Vec<serde_json::Value>, String> = response
|
let result: Result<Vec<serde_json::Value>, String> = response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string());
|
.ok_or_else(|| "Invalid response format".to_string());
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
@ -2211,6 +2279,35 @@ mod tests {
|
|||||||
assert_eq!(err, "Cluster missing-id not found");
|
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]
|
#[test]
|
||||||
fn test_update_proxmox_cluster_rows_zero_means_not_found() {
|
fn test_update_proxmox_cluster_rows_zero_means_not_found() {
|
||||||
let rows: usize = 0;
|
let rows: usize = 0;
|
||||||
|
|||||||
@ -227,6 +227,8 @@ pub fn run() {
|
|||||||
commands::proxmox::remove_proxmox_cluster,
|
commands::proxmox::remove_proxmox_cluster,
|
||||||
commands::proxmox::update_proxmox_cluster,
|
commands::proxmox::update_proxmox_cluster,
|
||||||
commands::proxmox::ping_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,47 +96,46 @@ 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
|
||||||
let repo_list: Vec<APTRepository> = repos
|
.as_array()
|
||||||
.iter()
|
.ok_or_else(|| "Invalid response format: expected array".to_string())?;
|
||||||
.filter_map(|repo| {
|
let repo_list: Vec<APTRepository> = repos
|
||||||
let id = repo.get("id")?.as_str()?.to_string();
|
.iter()
|
||||||
let url = repo.get("url")?.as_str().unwrap_or("").to_string();
|
.filter_map(|repo| {
|
||||||
let distribution = repo
|
let id = repo.get("id")?.as_str()?.to_string();
|
||||||
.get("distribution")
|
let url = repo.get("url")?.as_str().unwrap_or("").to_string();
|
||||||
.and_then(|d| d.as_str())
|
let distribution = repo
|
||||||
.unwrap_or("")
|
.get("distribution")
|
||||||
.to_string();
|
.and_then(|d| d.as_str())
|
||||||
let component = repo
|
.unwrap_or("")
|
||||||
.get("component")
|
.to_string();
|
||||||
.and_then(|c| c.as_str())
|
let component = repo
|
||||||
.unwrap_or("")
|
.get("component")
|
||||||
.to_string();
|
.and_then(|c| c.as_str())
|
||||||
let enabled = repo
|
.unwrap_or("")
|
||||||
.get("enabled")
|
.to_string();
|
||||||
.and_then(|e| e.as_bool())
|
let enabled = repo
|
||||||
.unwrap_or(true);
|
.get("enabled")
|
||||||
let type_ = repo
|
.and_then(|e| e.as_bool())
|
||||||
.get("type")
|
.unwrap_or(true);
|
||||||
.and_then(|t| t.as_str())
|
let type_ = repo
|
||||||
.unwrap_or("deb")
|
.get("type")
|
||||||
.to_string();
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("deb")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
Some(APTRepository {
|
Some(APTRepository {
|
||||||
repository_id: id,
|
repository_id: id,
|
||||||
url,
|
url,
|
||||||
distribution,
|
distribution,
|
||||||
component,
|
component,
|
||||||
enabled,
|
enabled,
|
||||||
type_,
|
type_,
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.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,16 @@ 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 +151,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 +184,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 +205,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 +227,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 +248,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 +320,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 +342,77 @@ 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 {
|
|
||||||
Ok(vec![data.clone()])
|
|
||||||
}
|
|
||||||
} else {
|
} 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))?;
|
.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,57 +131,64 @@ 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
|
|
||||||
.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()?;
|
|
||||||
|
|
||||||
Some(VmInfo {
|
let vms: Vec<VmInfo> = resources
|
||||||
id: vmid as u32,
|
.iter()
|
||||||
name,
|
.filter_map(|r| {
|
||||||
status,
|
let vmid = r.get("vmid")?.as_u64()?;
|
||||||
cpu,
|
let node = r.get("node")?.as_str()?.to_string();
|
||||||
memory: r.get("mem").and_then(|m| m.as_u64()).unwrap_or(0),
|
// Only include qemu VMs (not LXC containers which also appear in cluster/resources?type=vm)
|
||||||
disk: r.get("disk").and_then(|d| d.as_u64()).unwrap_or(0),
|
let resource_type = r.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
uptime: r.get("uptime").and_then(|u| u.as_u64()).unwrap_or(0),
|
if resource_type != "qemu" {
|
||||||
node,
|
return None;
|
||||||
template: r.get("template").and_then(|t| t.as_bool()),
|
}
|
||||||
agent: r
|
let name = r
|
||||||
.get("agent")
|
.get("name")
|
||||||
.and_then(|a| a.as_str())
|
.and_then(|n| n.as_str())
|
||||||
.map(|s| s.to_string()),
|
.map(|s| s.to_string());
|
||||||
mem: r.get("mem").and_then(|m| m.as_u64()),
|
let status = r
|
||||||
max_mem: r.get("maxmem").and_then(|m| m.as_u64()),
|
.get("status")
|
||||||
max_disk: r.get("maxdisk").and_then(|d| d.as_u64()),
|
.and_then(|s| s.as_str())
|
||||||
netin: r.get("netin").and_then(|n| n.as_u64()),
|
.unwrap_or("unknown")
|
||||||
netout: r.get("netout").and_then(|n| n.as_u64()),
|
.to_string();
|
||||||
diskread: r.get("diskread").and_then(|d| d.as_u64()),
|
// cpu may be absent for stopped VMs
|
||||||
diskwrite: r.get("diskwrite").and_then(|d| d.as_u64()),
|
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)
|
Ok(vms)
|
||||||
} else {
|
|
||||||
Err("Invalid response format: missing 'data' field".to_string())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get VM details
|
/// Get VM details
|
||||||
@ -197,8 +204,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 +421,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)]
|
||||||
|
|||||||
@ -66,8 +66,25 @@ export async function updateProxmoxCluster(
|
|||||||
* Ping a Proxmox cluster — authenticates and calls the version endpoint to verify
|
* Ping a Proxmox cluster — authenticates and calls the version endpoint to verify
|
||||||
* the API is reachable and credentials are valid.
|
* the API is reachable and credentials are valid.
|
||||||
*/
|
*/
|
||||||
export async function pingProxmoxCluster(clusterId: string): Promise<any> {
|
export async function pingProxmoxCluster(clusterId: string): Promise<unknown> {
|
||||||
return await invoke<any>("ping_proxmox_cluster", { clusterId });
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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, updateProxmoxCluster, pingProxmoxCluster } 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';
|
||||||
|
|
||||||
@ -134,8 +134,8 @@ export function ProxmoxRemotesPage() {
|
|||||||
|
|
||||||
const handleConnectRemote = async (remote: RemoteInfo) => {
|
const handleConnectRemote = async (remote: RemoteInfo) => {
|
||||||
try {
|
try {
|
||||||
toast.info(`Testing connection to ${remote.name}...`);
|
toast.info(`Connecting to ${remote.name}...`);
|
||||||
await pingProxmoxCluster(remote.id);
|
await connectProxmoxCluster(remote.id);
|
||||||
toast.success(`Connected to ${remote.name}`);
|
toast.success(`Connected to ${remote.name}`);
|
||||||
setRemotes((prev) =>
|
setRemotes((prev) =>
|
||||||
prev.map((r) => (r.id === remote.id ? { ...r, status: 'connected' } : r))
|
prev.map((r) => (r.id === remote.id ? { ...r, status: 'connected' } : r))
|
||||||
@ -149,11 +149,17 @@ export function ProxmoxRemotesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisconnectRemote = (remote: RemoteInfo) => {
|
const handleDisconnectRemote = async (remote: RemoteInfo) => {
|
||||||
setRemotes((prev) =>
|
try {
|
||||||
prev.map((r) => (r.id === remote.id ? { ...r, status: 'disconnected' } : r))
|
await disconnectProxmoxCluster(remote.id);
|
||||||
);
|
setRemotes((prev) =>
|
||||||
toast.info(`Disconnected from ${remote.name}`);
|
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 (
|
||||||
@ -184,12 +190,8 @@ export function ProxmoxRemotesPage() {
|
|||||||
onDelete={(remote) => {
|
onDelete={(remote) => {
|
||||||
setRemovingRemote(remote as RemoteInfo | null);
|
setRemovingRemote(remote as RemoteInfo | null);
|
||||||
}}
|
}}
|
||||||
onConnect={(remote) => {
|
onConnect={(remote) => { void handleConnectRemote(remote as RemoteInfo); }}
|
||||||
void handleConnectRemote(remote as RemoteInfo);
|
onDisconnect={(remote) => { void handleDisconnectRemote(remote as RemoteInfo); }}
|
||||||
}}
|
|
||||||
onDisconnect={(remote) => {
|
|
||||||
handleDisconnectRemote(remote as RemoteInfo);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showAddDialog && (
|
{showAddDialog && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user